acemq_amqp.devcerts

Certificates for a broker on a laptop, and only for a broker on a laptop::

pip install "acemq-amqp[crypto]"

python -m acemq_amqp.devcerts --directory certs --broker localhost

An authority, a broker certificate and a client certificate, written as PEM alongside a rabbitmq.conf that points the broker at them. The alternative is six openssl invocations with a hand-written extensions file, which is how people end up developing against a plaintext broker instead — and then finding out on the day TLS is turned on that nothing was ever tested through it.

Everything written here carries ~acemq_amqp.security.DEVELOPMENT_MARKER — ACEMQ DEVELOPMENT ONLY - DO NOT TRUST — in its subject organisation, and the TLS code in acemq_amqp.security refuses any certificate carrying it, whatever the trust settings say and including ~acemq_amqp.security.without_verifying_the_broker(). That is not a warning in a docstring somebody has to read. It is the mechanism: a generated authority's private key sits in the same directory as its certificate and usually ends up in a repository, so a certificate that could reach production would be an authority anybody who can read the repository can issue against.

Java, Go, Ruby and .NET generate the same three certificates with the same marker and enforce it the same way, so a broker set up by any of the five is usable from all of them and none of them will speak to it without being told the certificates are development ones. The file names match Go's acemq-certs exactly — ca.crt, ca.key, server.crt, server.key, client.crt, client.key — so this is a drop-in replacement for it in a script such as tls-broker.sh.

The keys are ECDSA on P-256, which is what Go writes. Java uses RSA-4096 and .NET RSA-2048; nothing reading these certificates cares, and P-256 keeps generation instant, which matters for something that runs at the top of a test script.

Needs the crypto extra, for the same reason acemq_amqp.codecs.encrypted does: the standard library has hashes and HMAC and does not have an X.509 builder.

  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"""Certificates for a broker on a laptop, and only for a broker on a laptop::
 16
 17    pip install "acemq-amqp[crypto]"
 18
 19    python -m acemq_amqp.devcerts --directory certs --broker localhost
 20
 21An authority, a broker certificate and a client certificate, written as PEM
 22alongside a ``rabbitmq.conf`` that points the broker at them. The alternative is
 23six ``openssl`` invocations with a hand-written extensions file, which is how
 24people end up developing against a plaintext broker instead — and then finding
 25out on the day TLS is turned on that nothing was ever tested through it.
 26
 27Everything written here carries
 28:data:`~acemq_amqp.security.DEVELOPMENT_MARKER` — ``ACEMQ DEVELOPMENT ONLY - DO
 29NOT TRUST`` — in its subject organisation, and **the TLS code in
 30:mod:`acemq_amqp.security` refuses any certificate carrying it**, whatever the
 31trust settings say and including :func:`~acemq_amqp.security.without_verifying_the_broker`.
 32That is not a warning in a docstring somebody has to read. It is the mechanism:
 33a generated authority's private key sits in the same directory as its
 34certificate and usually ends up in a repository, so a certificate that could
 35reach production would be an authority anybody who can read the repository can
 36issue against.
 37
 38Java, Go, Ruby and .NET generate the same three certificates with the same marker
 39and enforce it the same way, so a broker set up by any of the five is usable from
 40all of them and none of them will speak to it without being told the certificates
 41are development ones. The file names match Go's ``acemq-certs`` exactly — ``ca.crt``,
 42``ca.key``, ``server.crt``, ``server.key``, ``client.crt``, ``client.key`` — so
 43this is a drop-in replacement for it in a script such as ``tls-broker.sh``.
 44
 45The keys are ECDSA on P-256, which is what Go writes. Java uses RSA-4096 and
 46.NET RSA-2048; nothing reading these certificates cares, and P-256 keeps
 47generation instant, which matters for something that runs at the top of a test
 48script.
 49
 50Needs the ``crypto`` extra, for the same reason
 51:mod:`acemq_amqp.codecs.encrypted` does: the standard library has hashes and
 52HMAC and does not have an X.509 builder.
 53"""
 54
 55from __future__ import annotations
 56
 57import argparse
 58import datetime as dt
 59import ipaddress
 60import os
 61import secrets
 62import sys
 63from dataclasses import dataclass, field
 64from pathlib import Path
 65from typing import Any
 66
 67from .errors import AceMQError
 68from .security import DEVELOPMENT_MARKER
 69
 70#: What the authority calls itself. The same string in Java, Go, Ruby and .NET.
 71AUTHORITY_NAME = "AceMQ development CA"
 72
 73#: What the client certificate calls itself. Java, Go and Ruby all write this;
 74#: .NET writes ``acemq-dev-client``, and nothing reads either.
 75CLIENT_NAME = "acemq-client"
 76
 77#: How long a generated certificate lasts, in days.
 78#:
 79#: Thirty, matching Go and Java. Long enough not to interrupt a piece of work and
 80#: short enough that a set of these cannot quietly become load-bearing: a
 81#: development certificate that outlives the reason it was made is a development
 82#: certificate somebody eventually deploys.
 83DEFAULT_VALIDITY_DAYS = 30
 84
 85#: Where the broker will find the certificates *inside its container*, which is
 86#: rarely where they were written on the host.
 87DEFAULT_BROKER_CERTIFICATE_PATH = "/certs"
 88
 89
 90@dataclass(frozen=True, slots=True)
 91class GeneratedCertificates:
 92    """What :func:`generate` wrote.
 93
 94    :param directory: where the files are
 95    :param authority: the ``ca.crt`` path
 96    :param authority_key: the ``ca.key`` path
 97    :param server: the ``server.crt`` path
 98    :param server_key: the ``server.key`` path
 99    :param client: the ``client.crt`` path
100    :param client_key: the ``client.key`` path
101    :param broker_configuration: the ``rabbitmq.conf`` path, or ``None`` when one
102        was not asked for
103    :param expires: when all three certificates stop being valid, in UTC
104    :param broker_host: the name the broker certificate is valid for
105    :param marker: the string stamped into every subject, so a caller can print
106        it without importing it from somewhere else
107    """
108
109    directory: Path
110    authority: Path
111    authority_key: Path
112    server: Path
113    server_key: Path
114    client: Path
115    client_key: Path
116    broker_configuration: Path | None
117    expires: dt.datetime
118    broker_host: str
119    marker: str = DEVELOPMENT_MARKER
120    files: tuple[Path, ...] = field(default_factory=tuple)
121
122
123def generate(
124    directory: str | os.PathLike[str] = "certs",
125    *,
126    broker_host: str = "localhost",
127    validity_days: int = DEFAULT_VALIDITY_DAYS,
128    broker_certificate_path: str = DEFAULT_BROKER_CERTIFICATE_PATH,
129    write_broker_configuration: bool = True,
130) -> GeneratedCertificates:
131    """Writes an authority, a broker certificate and a client certificate.
132
133    :param directory: where to write them. Created if it is not there
134    :param broker_host: the name the broker certificate is valid for. An IP
135        address is recognised and written as one, because a certificate that
136        names ``10.0.0.4`` in its DNS names is valid for a host called
137        ``10.0.0.4`` and for no address at all
138    :param validity_days: how long they last
139    :param broker_certificate_path: where the broker will find them, which for a
140        broker in a container is a path on the container's filesystem rather than
141        this one. Only used in the generated ``rabbitmq.conf``
142    :param write_broker_configuration: whether to write that ``rabbitmq.conf``
143    :returns: what was written
144    :raises AceMQError: when ``cryptography`` is not installed, when
145        ``validity_days`` is not at least a day, or when a file cannot be written
146    """
147    if validity_days < 1:
148        raise AceMQError(
149            f"acemq: a certificate has to last at least a day and {validity_days} is not one"
150        )
151
152    x509, ec, hashes, serialization = _cryptography()
153
154    where = Path(directory)
155    try:
156        where.mkdir(parents=True, exist_ok=True)
157    except OSError as unwritable:
158        raise AceMQError(f"acemq: cannot create {str(where)!r}: {unwritable}") from unwritable
159
160    now = dt.datetime.now(dt.timezone.utc)
161    # An hour of slack at the front, because a container's clock and the host's
162    # are not the same clock and a certificate that is not valid yet fails in a
163    # way that reads exactly like a certificate that is wrong.
164    valid_from = now - dt.timedelta(hours=1)
165    valid_until = now + dt.timedelta(days=validity_days)
166
167    authority_key = ec.generate_private_key(ec.SECP256R1())
168    authority = (
169        _builder(x509, AUTHORITY_NAME, AUTHORITY_NAME, valid_from, valid_until, authority_key)
170        .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
171        .add_extension(
172            x509.KeyUsage(
173                digital_signature=True,
174                content_commitment=False,
175                key_encipherment=False,
176                data_encipherment=False,
177                key_agreement=False,
178                key_cert_sign=True,
179                crl_sign=True,
180                encipher_only=False,
181                decipher_only=False,
182            ),
183            critical=True,
184        )
185        .sign(authority_key, hashes.SHA256())
186    )
187
188    server_key = ec.generate_private_key(ec.SECP256R1())
189    server = _leaf(
190        x509,
191        hashes,
192        common_name=broker_host,
193        key=server_key,
194        authority=authority,
195        authority_key=authority_key,
196        valid_from=valid_from,
197        valid_until=valid_until,
198        purpose=x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
199        names=_names_for(x509, broker_host),
200    )
201
202    client_key = ec.generate_private_key(ec.SECP256R1())
203    client = _leaf(
204        x509,
205        hashes,
206        common_name=CLIENT_NAME,
207        key=client_key,
208        authority=authority,
209        authority_key=authority_key,
210        valid_from=valid_from,
211        valid_until=valid_until,
212        purpose=x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
213        names=None,
214    )
215
216    written: list[Path] = []
217    for name, contents, mode in (
218        ("ca.crt", _certificate_pem(serialization, authority), 0o644),
219        ("ca.key", _key_pem(serialization, authority_key), 0o600),
220        ("server.crt", _certificate_pem(serialization, server), 0o644),
221        ("server.key", _key_pem(serialization, server_key), 0o600),
222        ("client.crt", _certificate_pem(serialization, client), 0o644),
223        ("client.key", _key_pem(serialization, client_key), 0o600),
224    ):
225        written.append(_write(where / name, contents, mode))
226
227    configuration: Path | None = None
228    if write_broker_configuration:
229        configuration = _write(
230            where / "rabbitmq.conf",
231            broker_configuration(broker_certificate_path).encode("utf-8"),
232            0o644,
233        )
234        written.append(configuration)
235
236    return GeneratedCertificates(
237        directory=where,
238        authority=where / "ca.crt",
239        authority_key=where / "ca.key",
240        server=where / "server.crt",
241        server_key=where / "server.key",
242        client=where / "client.crt",
243        client_key=where / "client.key",
244        broker_configuration=configuration,
245        expires=valid_until,
246        broker_host=broker_host,
247        files=tuple(written),
248    )
249
250
251def broker_configuration(certificate_path: str = DEFAULT_BROKER_CERTIFICATE_PATH) -> str:
252    """A ``rabbitmq.conf`` pointing the broker at the generated certificates.
253
254    ``verify_peer`` with ``fail_if_no_peer_cert = false``, which is the setting
255    that lets one broker serve both cases: a client presenting no certificate
256    logs in with a password, and one presenting a certificate has it checked
257    against the authority. Turning ``fail_if_no_peer_cert`` on is what makes it
258    mutual TLS, and is a one-line change here rather than a different broker.
259
260    The plaintext listener stays on so the same broker can be reached either way
261    while somebody is working out which of the two is broken.
262
263    :param certificate_path: where the broker will find the files, on its own
264        filesystem
265    :returns: the configuration file's contents
266    """
267    return f"""# Written by acemq_amqp.devcerts. Development only.
268listeners.tcp.default = 5672
269listeners.ssl.default = 5671
270
271ssl_options.cacertfile = {certificate_path}/ca.crt
272ssl_options.certfile   = {certificate_path}/server.crt
273ssl_options.keyfile    = {certificate_path}/server.key
274
275# A client that presents a certificate has it checked; one that presents none
276# logs in with a password. Setting fail_if_no_peer_cert to true is what turns
277# this into mutual TLS.
278ssl_options.verify               = verify_peer
279ssl_options.fail_if_no_peer_cert = false
280"""
281
282
283def _cryptography() -> tuple[Any, Any, Any, Any]:
284    """The four pieces of ``cryptography`` this module uses, imported lazily."""
285    try:
286        from cryptography import x509
287        from cryptography.hazmat.primitives import hashes, serialization
288        from cryptography.hazmat.primitives.asymmetric import ec
289    except ImportError as missing:  # pragma: no cover - depends on the install
290        raise AceMQError(
291            "acemq: generating development certificates needs cryptography, which is an "
292            'optional extra. Install it with pip install "acemq-amqp[crypto]"'
293        ) from missing
294    return x509, ec, hashes, serialization
295
296
297def _subject(x509: Any, common_name: str) -> Any:
298    """A distinguished name carrying the marker in its organisation.
299
300    ``O=`` rather than ``OU=``, which is where Go and .NET put it. Java uses
301    ``OU=`` and every library detects the marker by searching the whole
302    distinguished name, so the two are interchangeable in practice — but two of
303    the three write ``O=`` and the difference is not worth having.
304    """
305    return x509.Name(
306        [
307            x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, common_name),
308            x509.NameAttribute(x509.oid.NameOID.ORGANIZATION_NAME, DEVELOPMENT_MARKER),
309        ]
310    )
311
312
313def _builder(
314    x509: Any,
315    subject: str,
316    issuer: str,
317    valid_from: dt.datetime,
318    valid_until: dt.datetime,
319    key: Any,
320) -> Any:
321    return (
322        x509.CertificateBuilder()
323        .subject_name(_subject(x509, subject))
324        .issuer_name(_subject(x509, issuer))
325        .public_key(key.public_key())
326        .serial_number(int.from_bytes(secrets.token_bytes(16), "big") >> 1 or 1)
327        .not_valid_before(valid_from)
328        .not_valid_after(valid_until)
329    )
330
331
332def _leaf(
333    x509: Any,
334    hashes: Any,
335    *,
336    common_name: str,
337    key: Any,
338    authority: Any,
339    authority_key: Any,
340    valid_from: dt.datetime,
341    valid_until: dt.datetime,
342    purpose: Any,
343    names: Any,
344) -> Any:
345    builder = (
346        x509.CertificateBuilder()
347        .subject_name(_subject(x509, common_name))
348        .issuer_name(authority.subject)
349        .public_key(key.public_key())
350        .serial_number(int.from_bytes(secrets.token_bytes(16), "big") >> 1 or 1)
351        .not_valid_before(valid_from)
352        .not_valid_after(valid_until)
353        .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
354        .add_extension(
355            x509.KeyUsage(
356                digital_signature=True,
357                content_commitment=False,
358                key_encipherment=True,
359                data_encipherment=False,
360                key_agreement=False,
361                key_cert_sign=False,
362                crl_sign=False,
363                encipher_only=False,
364                decipher_only=False,
365            ),
366            critical=True,
367        )
368        # One purpose each, as Go and .NET write them: the broker certificate is
369        # for serving and the client one for authenticating. Java gives both to
370        # both, which works and says less about what each is for.
371        .add_extension(x509.ExtendedKeyUsage([purpose]), critical=False)
372    )
373    if names is not None:
374        builder = builder.add_extension(names, critical=False)
375    return builder.sign(authority_key, hashes.SHA256())
376
377
378def _names_for(x509: Any, broker_host: str) -> Any:
379    """The names the broker certificate is valid for.
380
381    An IP address goes in as an address rather than a DNS name — a certificate
382    listing ``10.0.0.4`` under DNS is valid for a *host called* ``10.0.0.4``,
383    which nothing is, and the connection fails for a reason that reads like the
384    certificate being wrong rather than like this.
385
386    ``localhost`` picks up both loopback addresses, because half the tooling
387    dials the name and the other half dials ``127.0.0.1``. ``rabbitmq`` comes
388    along too, which is what the broker is called on a Docker network and is what
389    Java adds for the same reason.
390    """
391    try:
392        address = ipaddress.ip_address(broker_host)
393    except ValueError:
394        pass
395    else:
396        return x509.SubjectAlternativeName([x509.IPAddress(address)])
397
398    names = [x509.DNSName(broker_host)]
399    if broker_host != "localhost":
400        names.append(x509.DNSName("localhost"))
401    if broker_host != "rabbitmq":
402        names.append(x509.DNSName("rabbitmq"))
403    names.append(x509.IPAddress(ipaddress.ip_address("127.0.0.1")))
404    names.append(x509.IPAddress(ipaddress.ip_address("::1")))
405    return x509.SubjectAlternativeName(names)
406
407
408def _certificate_pem(serialization: Any, certificate: Any) -> bytes:
409    written: bytes = certificate.public_bytes(serialization.Encoding.PEM)
410    return written
411
412
413def _key_pem(serialization: Any, key: Any) -> bytes:
414    written: bytes = key.private_bytes(
415        encoding=serialization.Encoding.PEM,
416        format=serialization.PrivateFormat.PKCS8,
417        # Unencrypted, because a passphrase held in the same script that reads
418        # the key protects nothing, and one held anywhere else turns a
419        # development broker into something with a setup step.
420        encryption_algorithm=serialization.NoEncryption(),
421    )
422    return written
423
424
425def _write(path: Path, contents: bytes, mode: int) -> Path:
426    """Writes a file, then narrows it. Private keys end up ``0600``.
427
428    Narrowed after writing rather than by opening with the mode, because an
429    existing file keeps the permissions it already had and a key regenerated over
430    a world-readable one would quietly stay world-readable.
431    """
432    try:
433        path.write_bytes(contents)
434        os.chmod(path, mode)
435    except OSError as unwritable:
436        raise AceMQError(f"acemq: cannot write {str(path)!r}: {unwritable}") from unwritable
437    return path
438
439
440def main(argv: list[str] | None = None) -> int:
441    """``python -m acemq_amqp.devcerts``.
442
443    :param argv: the arguments, or ``None`` to read :data:`sys.argv`
444    :returns: the process exit status
445    """
446    parser = argparse.ArgumentParser(
447        prog="python -m acemq_amqp.devcerts",
448        description=(
449            "Write a development certificate authority, a broker certificate and a client "
450            "certificate. Everything is stamped "
451            f'"{DEVELOPMENT_MARKER}" and the AceMQ libraries refuse it however trust is '
452            "configured, which is what keeps it off anything real."
453        ),
454    )
455    parser.add_argument(
456        "--directory", "-d", default="certs", help="where to write them (default: certs)"
457    )
458    parser.add_argument(
459        "--broker",
460        "-b",
461        default="localhost",
462        help="the name or address the broker certificate is valid for (default: localhost)",
463    )
464    parser.add_argument(
465        "--days",
466        type=int,
467        default=DEFAULT_VALIDITY_DAYS,
468        help=f"how long they last (default: {DEFAULT_VALIDITY_DAYS})",
469    )
470    parser.add_argument(
471        "--broker-certificate-path",
472        default=DEFAULT_BROKER_CERTIFICATE_PATH,
473        help=(
474            "where the broker will find them on its own filesystem, for the generated "
475            f"rabbitmq.conf (default: {DEFAULT_BROKER_CERTIFICATE_PATH})"
476        ),
477    )
478    parser.add_argument(
479        "--no-broker-config",
480        action="store_true",
481        help="do not write rabbitmq.conf",
482    )
483    arguments = parser.parse_args(argv)
484
485    try:
486        result = generate(
487            arguments.directory,
488            broker_host=arguments.broker,
489            validity_days=arguments.days,
490            broker_certificate_path=arguments.broker_certificate_path,
491            write_broker_configuration=not arguments.no_broker_config,
492        )
493    except AceMQError as failure:
494        print(failure, file=sys.stderr)
495        return 1
496
497    for path in result.files:
498        print(path)
499    print(
500        f'valid for {result.broker_host} until {result.expires:%Y-%m-%d}. Stamped "'
501        f'{result.marker}", so every AceMQ library refuses these unless it is told to '
502        "allow them.",
503        file=sys.stderr,
504    )
505    return 0
506
507
508if __name__ == "__main__":  # pragma: no cover - the entry point itself
509    raise SystemExit(main())
AUTHORITY_NAME = 'AceMQ development CA'
CLIENT_NAME = 'acemq-client'
DEFAULT_VALIDITY_DAYS = 30
DEFAULT_BROKER_CERTIFICATE_PATH = '/certs'
@dataclass(frozen=True, slots=True)
class GeneratedCertificates:
 91@dataclass(frozen=True, slots=True)
 92class GeneratedCertificates:
 93    """What :func:`generate` wrote.
 94
 95    :param directory: where the files are
 96    :param authority: the ``ca.crt`` path
 97    :param authority_key: the ``ca.key`` path
 98    :param server: the ``server.crt`` path
 99    :param server_key: the ``server.key`` path
100    :param client: the ``client.crt`` path
101    :param client_key: the ``client.key`` path
102    :param broker_configuration: the ``rabbitmq.conf`` path, or ``None`` when one
103        was not asked for
104    :param expires: when all three certificates stop being valid, in UTC
105    :param broker_host: the name the broker certificate is valid for
106    :param marker: the string stamped into every subject, so a caller can print
107        it without importing it from somewhere else
108    """
109
110    directory: Path
111    authority: Path
112    authority_key: Path
113    server: Path
114    server_key: Path
115    client: Path
116    client_key: Path
117    broker_configuration: Path | None
118    expires: dt.datetime
119    broker_host: str
120    marker: str = DEVELOPMENT_MARKER
121    files: tuple[Path, ...] = field(default_factory=tuple)

What generate() wrote.

Parameters
  • directory: where the files are
  • authority: the ca.crt path
  • authority_key: the ca.key path
  • server: the server.crt path
  • server_key: the server.key path
  • client: the client.crt path
  • client_key: the client.key path
  • broker_configuration: the rabbitmq.conf path, or None when one was not asked for
  • expires: when all three certificates stop being valid, in UTC
  • broker_host: the name the broker certificate is valid for
  • marker: the string stamped into every subject, so a caller can print it without importing it from somewhere else
GeneratedCertificates( directory: pathlib.Path, authority: pathlib.Path, authority_key: pathlib.Path, server: pathlib.Path, server_key: pathlib.Path, client: pathlib.Path, client_key: pathlib.Path, broker_configuration: pathlib.Path | None, expires: datetime.datetime, broker_host: str, marker: str = 'ACEMQ DEVELOPMENT ONLY - DO NOT TRUST', files: tuple[pathlib.Path, ...] = <factory>)
directory: pathlib.Path
authority: pathlib.Path
authority_key: pathlib.Path
server: pathlib.Path
server_key: pathlib.Path
client: pathlib.Path
client_key: pathlib.Path
broker_configuration: pathlib.Path | None
expires: datetime.datetime
broker_host: str
marker: str
files: tuple[pathlib.Path, ...]
def generate( directory: str | os.PathLike[str] = 'certs', *, broker_host: str = 'localhost', validity_days: int = 30, broker_certificate_path: str = '/certs', write_broker_configuration: bool = True) -> GeneratedCertificates:
124def generate(
125    directory: str | os.PathLike[str] = "certs",
126    *,
127    broker_host: str = "localhost",
128    validity_days: int = DEFAULT_VALIDITY_DAYS,
129    broker_certificate_path: str = DEFAULT_BROKER_CERTIFICATE_PATH,
130    write_broker_configuration: bool = True,
131) -> GeneratedCertificates:
132    """Writes an authority, a broker certificate and a client certificate.
133
134    :param directory: where to write them. Created if it is not there
135    :param broker_host: the name the broker certificate is valid for. An IP
136        address is recognised and written as one, because a certificate that
137        names ``10.0.0.4`` in its DNS names is valid for a host called
138        ``10.0.0.4`` and for no address at all
139    :param validity_days: how long they last
140    :param broker_certificate_path: where the broker will find them, which for a
141        broker in a container is a path on the container's filesystem rather than
142        this one. Only used in the generated ``rabbitmq.conf``
143    :param write_broker_configuration: whether to write that ``rabbitmq.conf``
144    :returns: what was written
145    :raises AceMQError: when ``cryptography`` is not installed, when
146        ``validity_days`` is not at least a day, or when a file cannot be written
147    """
148    if validity_days < 1:
149        raise AceMQError(
150            f"acemq: a certificate has to last at least a day and {validity_days} is not one"
151        )
152
153    x509, ec, hashes, serialization = _cryptography()
154
155    where = Path(directory)
156    try:
157        where.mkdir(parents=True, exist_ok=True)
158    except OSError as unwritable:
159        raise AceMQError(f"acemq: cannot create {str(where)!r}: {unwritable}") from unwritable
160
161    now = dt.datetime.now(dt.timezone.utc)
162    # An hour of slack at the front, because a container's clock and the host's
163    # are not the same clock and a certificate that is not valid yet fails in a
164    # way that reads exactly like a certificate that is wrong.
165    valid_from = now - dt.timedelta(hours=1)
166    valid_until = now + dt.timedelta(days=validity_days)
167
168    authority_key = ec.generate_private_key(ec.SECP256R1())
169    authority = (
170        _builder(x509, AUTHORITY_NAME, AUTHORITY_NAME, valid_from, valid_until, authority_key)
171        .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
172        .add_extension(
173            x509.KeyUsage(
174                digital_signature=True,
175                content_commitment=False,
176                key_encipherment=False,
177                data_encipherment=False,
178                key_agreement=False,
179                key_cert_sign=True,
180                crl_sign=True,
181                encipher_only=False,
182                decipher_only=False,
183            ),
184            critical=True,
185        )
186        .sign(authority_key, hashes.SHA256())
187    )
188
189    server_key = ec.generate_private_key(ec.SECP256R1())
190    server = _leaf(
191        x509,
192        hashes,
193        common_name=broker_host,
194        key=server_key,
195        authority=authority,
196        authority_key=authority_key,
197        valid_from=valid_from,
198        valid_until=valid_until,
199        purpose=x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
200        names=_names_for(x509, broker_host),
201    )
202
203    client_key = ec.generate_private_key(ec.SECP256R1())
204    client = _leaf(
205        x509,
206        hashes,
207        common_name=CLIENT_NAME,
208        key=client_key,
209        authority=authority,
210        authority_key=authority_key,
211        valid_from=valid_from,
212        valid_until=valid_until,
213        purpose=x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
214        names=None,
215    )
216
217    written: list[Path] = []
218    for name, contents, mode in (
219        ("ca.crt", _certificate_pem(serialization, authority), 0o644),
220        ("ca.key", _key_pem(serialization, authority_key), 0o600),
221        ("server.crt", _certificate_pem(serialization, server), 0o644),
222        ("server.key", _key_pem(serialization, server_key), 0o600),
223        ("client.crt", _certificate_pem(serialization, client), 0o644),
224        ("client.key", _key_pem(serialization, client_key), 0o600),
225    ):
226        written.append(_write(where / name, contents, mode))
227
228    configuration: Path | None = None
229    if write_broker_configuration:
230        configuration = _write(
231            where / "rabbitmq.conf",
232            broker_configuration(broker_certificate_path).encode("utf-8"),
233            0o644,
234        )
235        written.append(configuration)
236
237    return GeneratedCertificates(
238        directory=where,
239        authority=where / "ca.crt",
240        authority_key=where / "ca.key",
241        server=where / "server.crt",
242        server_key=where / "server.key",
243        client=where / "client.crt",
244        client_key=where / "client.key",
245        broker_configuration=configuration,
246        expires=valid_until,
247        broker_host=broker_host,
248        files=tuple(written),
249    )

Writes an authority, a broker certificate and a client certificate.

Parameters
  • directory: where to write them. Created if it is not there
  • broker_host: the name the broker certificate is valid for. An IP address is recognised and written as one, because a certificate that names 10.0.0.4 in its DNS names is valid for a host called 10.0.0.4 and for no address at all
  • validity_days: how long they last
  • broker_certificate_path: where the broker will find them, which for a broker in a container is a path on the container's filesystem rather than this one. Only used in the generated rabbitmq.conf
  • write_broker_configuration: whether to write that rabbitmq.conf :returns: what was written
Raises
  • AceMQError: when cryptography is not installed, when validity_days is not at least a day, or when a file cannot be written
def broker_configuration(certificate_path: str = '/certs') -> str:
252def broker_configuration(certificate_path: str = DEFAULT_BROKER_CERTIFICATE_PATH) -> str:
253    """A ``rabbitmq.conf`` pointing the broker at the generated certificates.
254
255    ``verify_peer`` with ``fail_if_no_peer_cert = false``, which is the setting
256    that lets one broker serve both cases: a client presenting no certificate
257    logs in with a password, and one presenting a certificate has it checked
258    against the authority. Turning ``fail_if_no_peer_cert`` on is what makes it
259    mutual TLS, and is a one-line change here rather than a different broker.
260
261    The plaintext listener stays on so the same broker can be reached either way
262    while somebody is working out which of the two is broken.
263
264    :param certificate_path: where the broker will find the files, on its own
265        filesystem
266    :returns: the configuration file's contents
267    """
268    return f"""# Written by acemq_amqp.devcerts. Development only.
269listeners.tcp.default = 5672
270listeners.ssl.default = 5671
271
272ssl_options.cacertfile = {certificate_path}/ca.crt
273ssl_options.certfile   = {certificate_path}/server.crt
274ssl_options.keyfile    = {certificate_path}/server.key
275
276# A client that presents a certificate has it checked; one that presents none
277# logs in with a password. Setting fail_if_no_peer_cert to true is what turns
278# this into mutual TLS.
279ssl_options.verify               = verify_peer
280ssl_options.fail_if_no_peer_cert = false
281"""

A rabbitmq.conf pointing the broker at the generated certificates.

verify_peer with fail_if_no_peer_cert = false, which is the setting that lets one broker serve both cases: a client presenting no certificate logs in with a password, and one presenting a certificate has it checked against the authority. Turning fail_if_no_peer_cert on is what makes it mutual TLS, and is a one-line change here rather than a different broker.

The plaintext listener stays on so the same broker can be reached either way while somebody is working out which of the two is broken.

Parameters
  • certificate_path: where the broker will find the files, on its own filesystem :returns: the configuration file's contents
def main(argv: list[str] | None = None) -> int:
441def main(argv: list[str] | None = None) -> int:
442    """``python -m acemq_amqp.devcerts``.
443
444    :param argv: the arguments, or ``None`` to read :data:`sys.argv`
445    :returns: the process exit status
446    """
447    parser = argparse.ArgumentParser(
448        prog="python -m acemq_amqp.devcerts",
449        description=(
450            "Write a development certificate authority, a broker certificate and a client "
451            "certificate. Everything is stamped "
452            f'"{DEVELOPMENT_MARKER}" and the AceMQ libraries refuse it however trust is '
453            "configured, which is what keeps it off anything real."
454        ),
455    )
456    parser.add_argument(
457        "--directory", "-d", default="certs", help="where to write them (default: certs)"
458    )
459    parser.add_argument(
460        "--broker",
461        "-b",
462        default="localhost",
463        help="the name or address the broker certificate is valid for (default: localhost)",
464    )
465    parser.add_argument(
466        "--days",
467        type=int,
468        default=DEFAULT_VALIDITY_DAYS,
469        help=f"how long they last (default: {DEFAULT_VALIDITY_DAYS})",
470    )
471    parser.add_argument(
472        "--broker-certificate-path",
473        default=DEFAULT_BROKER_CERTIFICATE_PATH,
474        help=(
475            "where the broker will find them on its own filesystem, for the generated "
476            f"rabbitmq.conf (default: {DEFAULT_BROKER_CERTIFICATE_PATH})"
477        ),
478    )
479    parser.add_argument(
480        "--no-broker-config",
481        action="store_true",
482        help="do not write rabbitmq.conf",
483    )
484    arguments = parser.parse_args(argv)
485
486    try:
487        result = generate(
488            arguments.directory,
489            broker_host=arguments.broker,
490            validity_days=arguments.days,
491            broker_certificate_path=arguments.broker_certificate_path,
492            write_broker_configuration=not arguments.no_broker_config,
493        )
494    except AceMQError as failure:
495        print(failure, file=sys.stderr)
496        return 1
497
498    for path in result.files:
499        print(path)
500    print(
501        f'valid for {result.broker_host} until {result.expires:%Y-%m-%d}. Stamped "'
502        f'{result.marker}", so every AceMQ library refuses these unless it is told to '
503        "allow them.",
504        file=sys.stderr,
505    )
506    return 0

python -m acemq_amqp.devcerts.

Parameters
  • argv: the arguments, or None to read sys.argv :returns: the process exit status