acemq_amqp.codecs.encrypted

Encrypts whatever another codec produced, so the broker holds ciphertext::

pip install "acemq-amqp[crypto]"

from acemq_amqp.codecs.encrypted import EncryptedCodec, Keyring, generate_key

keyring = Keyring.of("2026-01", generate_key())
mq = await connect(url, codec=EncryptedCodec(JsonCodec(), keyring))

It wraps a delegate rather than serialising anything itself, so choosing a format and choosing to encrypt stay independent: JSON in, AES-GCM out, and Avro just as well.

What is on the wire

::

0xAE  0x01  len  key identifier   12-byte nonce   ciphertext + 16-byte tag

The key identifier is in the message, in the clear. That is deliberate, and it is what makes rotation possible: a consumer reads which key a message needs rather than assuming the current one, so a new key can be introduced while messages written with the old one are still queued. Putting it in an AMQP header instead would have been tidier and would have lost it — headers are dropped by shovels, rewritten by federation, and absent from a message recovered out of a backup, and a ciphertext whose key nobody can name is gone.

The header is authenticated but not encrypted: GCM binds all of it — magic, version, length and identifier — as associated data, so an altered key identifier makes the message fail to open rather than quietly opening as something else. key_id_of() reads it back without needing any key, which is what an operator staring at an unreadable dead-letter queue actually wants.

This is the family framing, byte for byte, and all four of the other AceMQ libraries write and read it too:

=========== ====== ======= ========== ========== ================ ========== library magic version id length iv cipher tag =========== ====== ======= ========== ========== ================ ========== Java 0xAE 0x01 1 byte 12-byte AES-GCM 16 bytes Python 0xAE 0x01 1 byte 12-byte AES-GCM 16 bytes Ruby 0xAE 0x01 1 byte 12-byte AES-GCM 16 bytes Go 0xAE 0x01 1 byte 12-byte AES-GCM 16 bytes .NET 0xAE 0x01 1 byte 12-byte AES-GCM 16 bytes =========== ====== ======= ========== ========== ================ ==========

A body written by any of the five opens in any of the other four, given the key. .NET used to be the exception and no longer is: it wrote AES-256-CBC with a separate HMAC-SHA-256 up to its own 0.3.0 and moved here in the same round Go dropped the magic-less variant of its own. .NET still reads the bodies it wrote before that change, so a queue filled before it can be drained; nothing writes that framing now, and nothing else has ever read it.

The first byte is what makes the refusal honest. A body that does not begin 0xAE is not this framing, and it is refused as that — visibly, naming the framing — rather than being decrypted into something else or reported as a key problem.

tests/test_encrypted.py holds a complete test vector for a known key, nonce and plaintext. All five test suites pin the same one.

What this does not do

The broker can no longer read the message, and neither can the people who operate it. Decide what they do instead before turning this on: a dead-letter queue full of ciphertext is a queue nobody can triage, and the answer is usually a small internal tool holding the keyring rather than the management UI.

Encryption is not authorisation. Every service holding the keyring can read every message encrypted with those keys; the granularity is the key, so separate audiences mean separate keys. Anybody holding a key can also write a message this codec will decrypt without complaint, so a key is a shared secret and not a signature.

Nor does it hide the routing. Exchange, routing key, headers and message size stay in the clear, and for many systems the routing key is the sensitive part.

Nothing here ever puts a plaintext, a key, or any part of either into an exception, a log line or a repr(). A failure says which key identifier the message named and stops there, and the tests pin it.

  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"""Encrypts whatever another codec produced, so the broker holds ciphertext::
 16
 17    pip install "acemq-amqp[crypto]"
 18
 19    from acemq_amqp.codecs.encrypted import EncryptedCodec, Keyring, generate_key
 20
 21    keyring = Keyring.of("2026-01", generate_key())
 22    mq = await connect(url, codec=EncryptedCodec(JsonCodec(), keyring))
 23
 24It wraps a delegate rather than serialising anything itself, so choosing a
 25format and choosing to encrypt stay independent: JSON in, AES-GCM out, and Avro
 26just as well.
 27
 28What is on the wire
 29-------------------
 30
 31::
 32
 33    0xAE  0x01  len  key identifier   12-byte nonce   ciphertext + 16-byte tag
 34
 35**The key identifier is in the message, in the clear.** That is deliberate, and
 36it is what makes rotation possible: a consumer reads which key a message needs
 37rather than assuming the current one, so a new key can be introduced while
 38messages written with the old one are still queued. Putting it in an AMQP header
 39instead would have been tidier and would have lost it — headers are dropped by
 40shovels, rewritten by federation, and absent from a message recovered out of a
 41backup, and a ciphertext whose key nobody can name is gone.
 42
 43The header is authenticated but not encrypted: GCM binds all of it — magic,
 44version, length and identifier — as associated data, so an altered key
 45identifier makes the message fail to open rather than quietly opening as
 46something else. :func:`key_id_of` reads it back without needing any key, which
 47is what an operator staring at an unreadable dead-letter queue actually wants.
 48
 49This is the family framing, byte for byte, and **all four of the other AceMQ
 50libraries write and read it too**:
 51
 52===========  ======  =======  ==========  ==========  ================  ==========
 53library      magic   version  id length   iv          cipher            tag
 54===========  ======  =======  ==========  ==========  ================  ==========
 55Java         0xAE    0x01     1 byte      12-byte     AES-GCM           16 bytes
 56**Python**   0xAE    0x01     1 byte      12-byte     AES-GCM           16 bytes
 57Ruby         0xAE    0x01     1 byte      12-byte     AES-GCM           16 bytes
 58Go           0xAE    0x01     1 byte      12-byte     AES-GCM           16 bytes
 59.NET         0xAE    0x01     1 byte      12-byte     AES-GCM           16 bytes
 60===========  ======  =======  ==========  ==========  ================  ==========
 61
 62A body written by any of the five opens in any of the other four, given the key.
 63.NET used to be the exception and no longer is: it wrote AES-256-CBC with a
 64separate HMAC-SHA-256 up to its own 0.3.0 and moved here in the same round Go
 65dropped the magic-less variant of its own. .NET still reads the bodies it wrote
 66before that change, so a queue filled before it can be drained; nothing writes
 67that framing now, and nothing else has ever read it.
 68
 69The first byte is what makes the refusal honest. A body that does not begin
 70``0xAE`` is not this framing, and it is refused as that — visibly, naming the
 71framing — rather than being decrypted into something else or reported as a key
 72problem.
 73
 74``tests/test_encrypted.py`` holds a complete test vector for a known key, nonce
 75and plaintext. All five test suites pin the same one.
 76
 77What this does not do
 78---------------------
 79
 80The broker can no longer read the message, and neither can the people who
 81operate it. **Decide what they do instead before turning this on**: a
 82dead-letter queue full of ciphertext is a queue nobody can triage, and the
 83answer is usually a small internal tool holding the keyring rather than the
 84management UI.
 85
 86Encryption is not authorisation. Every service holding the keyring can read
 87every message encrypted with those keys; the granularity is the key, so separate
 88audiences mean separate keys. Anybody holding a key can also *write* a message
 89this codec will decrypt without complaint, so a key is a shared secret and not a
 90signature.
 91
 92Nor does it hide the routing. Exchange, routing key, headers and message size
 93stay in the clear, and for many systems the routing key is the sensitive part.
 94
 95Nothing here ever puts a plaintext, a key, or any part of either into an
 96exception, a log line or a :func:`repr`. A failure says which key identifier the
 97message named and stops there, and the tests pin it.
 98"""
 99
100from __future__ import annotations
101
102import base64
103import os
104from typing import Any
105
106from ..ack import FatalError
107from ..codec import Codec
108from ..errors import AceMQError
109
110#: What this codec writes, and what Java writes.
111#:
112#: Deliberately not ``...+json``, whatever the plaintext underneath is. A
113#: ``+json`` suffix is a promise that the bytes on the wire are JSON, and every
114#: JSON-aware consumer reads it that way. These bytes are ciphertext. Naming
115#: them ``+json`` makes the JSON codec volunteer to decode them, which is how a
116#: message ends up failing in a parser rather than being refused by a codec that
117#: knows it cannot help.
118ENCRYPTED_CONTENT_TYPE = "application/vnd.acemq.encrypted"
119
120#: Marks the framing as this codec's, so a message from elsewhere is refused
121#: rather than decrypted.
122MAGIC = 0xAE
123
124#: Version 1. Present so a later framing can be told apart from this one by its
125#: first two bytes.
126VERSION = 0x01
127
128#: GCM's nonce, in bytes. Twelve is the size the construction is defined for;
129#: any other length sends the nonce through an extra hash and buys nothing.
130NONCE_BYTES = 12
131
132#: The authentication tag, in bytes. 128 bits, which is what Java and Go use and
133#: the only length worth using: a truncated tag is a cheaper forgery.
134TAG_BYTES = 16
135
136#: The longest a key identifier may be, in UTF-8 bytes. It is one byte of
137#: framing in front of every message, so it is a name rather than a description.
138MAX_KEY_ID_BYTES = 255
139
140#: The key lengths AES takes. 32 bytes — AES-256 — is what :func:`generate_key`
141#: produces; 16 and 24 are accepted because Java accepts them and a key that
142#: already exists should not need re-issuing to be readable here.
143KEY_SIZES = (16, 24, 32)
144
145
146def generate_key() -> bytes:
147    """Draws a fresh 32-byte key from the operating system's random source.
148
149    :returns: a new AES-256 key
150    """
151    return os.urandom(32)
152
153
154def key_from_base64(encoded: str) -> bytes:
155    """Reads a key out of Base64, which is how one travels in configuration.
156
157    :param encoded: the Base64 text, with surrounding whitespace ignored
158    :returns: the key bytes
159    :raises AceMQError: when the text is not Base64, or does not decode to a
160        length AES takes. The message never quotes the input, because the input
161        is a key
162    """
163    try:
164        key = base64.b64decode(encoded.strip(), validate=True)
165    except (ValueError, TypeError) as failure:
166        # Deliberately not echoing ``encoded``: it is the key, and an exception
167        # is the one place it must never be written down.
168        raise AceMQError(
169            "acemq: this is not Base64, so no key could be read from it: "
170            f"{type(failure).__name__}"
171        ) from None
172    _check_key_size(key)
173    return key
174
175
176def key_to_base64(key: bytes) -> str:
177    """Renders a key as Base64.
178
179    The inverse of :func:`key_from_base64`, for writing a generated key into a
180    secret store. Whatever this returns is the key itself, so it belongs in the
181    same place the database password does and nowhere else.
182
183    :param key: the key bytes
184    :returns: the Base64 text
185    """
186    return base64.b64encode(key).decode("ascii")
187
188
189def _check_key_size(key: bytes) -> None:
190    if len(key) not in KEY_SIZES:
191        raise AceMQError(
192            f"acemq: an AES key is 16, 24 or 32 bytes and this one is {len(key)}. If it came "
193            "from a passphrase it needs a key derivation function such as PBKDF2, scrypt or "
194            "Argon2 rather than being used as it stands"
195        )
196
197
198class EncryptionKey:
199    """One key, and the name a message calls it by.
200
201    :param id: what the key is called on the wire. Something datelike —
202        ``2026-01`` — reads well when somebody is working out what to rotate.
203        At most 255 UTF-8 bytes, because it travels in front of every message
204    :param key: 16, 24 or 32 bytes of key material
205    :raises AceMQError: when the identifier is empty or too long, or the key is
206        not a length AES takes
207    """
208
209    __slots__ = ("_id", "_key")
210
211    def __init__(self, id: str, key: bytes) -> None:
212        if not id:
213            raise AceMQError(
214                "acemq: a key identifier cannot be empty: it is what a reader looks "
215                "the key up by"
216            )
217        encoded = id.encode("utf-8")
218        if len(encoded) > MAX_KEY_ID_BYTES:
219            raise AceMQError(
220                f"acemq: a key identifier is at most {MAX_KEY_ID_BYTES} bytes and {id!r} is "
221                f"{len(encoded)}. It travels in front of every message, so it is a name rather "
222                "than a description"
223            )
224        if "\x00" in id:
225            raise AceMQError(f"acemq: the key identifier {id!r} contains a null byte")
226        _check_key_size(key)
227        self._id = id
228        self._key = bytes(key)
229
230    @property
231    def id(self) -> str:
232        """What a message calls this key."""
233        return self._id
234
235    @property
236    def key(self) -> bytes:
237        """The key material."""
238        return self._key
239
240    def __eq__(self, other: object) -> bool:
241        if not isinstance(other, EncryptionKey):
242            return NotImplemented
243        return self._id == other._id and self._key == other._key
244
245    def __hash__(self) -> int:
246        return hash((self._id, self._key))
247
248    def __repr__(self) -> str:
249        """Names the key and never shows it.
250
251        A key logged by accident — in a dataclass dump, in an exception, in a
252        debugger's variables pane — prints its identifier and nothing else. That
253        is the whole reason this is a type rather than a tuple, and a test pins
254        it.
255        """
256        return f"EncryptionKey(id={self._id!r})"
257
258    __str__ = __repr__
259
260
261class Keyring:
262    """The keys a process can use, and which one it writes with.
263
264    More than one, because rotation needs an overlap: new messages are written
265    with the newest key while messages written with the old one are still being
266    read. A keyring with one key cannot rotate without an outage, and the order
267    to do it in is *add the new key everywhere first*, so every consumer can read
268    it, and only then :meth:`use` it somewhere.
269
270    :param keys: the keys to hold. The first is the one written with, unless
271        :meth:`use` says otherwise
272    :raises AceMQError: when given no keys at all
273    """
274
275    __slots__ = ("_current", "_keys")
276
277    def __init__(self, *keys: EncryptionKey) -> None:
278        if not keys:
279            raise AceMQError(
280                "acemq: a keyring needs at least one key; there is nothing to write with"
281            )
282        self._keys: dict[str, EncryptionKey] = {}
283        for key in keys:
284            self._keys[key.id] = key
285        self._current = keys[0].id
286
287    @classmethod
288    def of(cls, id: str, key: bytes) -> Keyring:
289        """A keyring holding one key, which is where every rotation starts.
290
291        :param id: what the key is called on the wire
292        :param key: the key material
293        :returns: the keyring
294        """
295        return cls(EncryptionKey(id, key))
296
297    def add(self, key: EncryptionKey) -> None:
298        """Puts a key on the ring without making it the one written with.
299
300        :param key: the key to hold
301        """
302        self._keys[key.id] = key
303
304    def use(self, id: str) -> None:
305        """Makes a key the one new messages are written with.
306
307        :param id: which key
308        :raises AceMQError: when that key is not on the ring
309        """
310        if id not in self._keys:
311            raise AceMQError(f"acemq: there is no key {id!r} on this keyring")
312        self._current = id
313
314    @property
315    def current(self) -> EncryptionKey:
316        """The key new messages are written with."""
317        return self._keys[self._current]
318
319    @property
320    def ids(self) -> tuple[str, ...]:
321        """The keys on the ring, in the order they were added. Never the keys
322        themselves — this is for a health endpoint or a line at start-up."""
323        return tuple(self._keys)
324
325    def key_for(self, id: str) -> EncryptionKey:
326        """Looks a key up by the name a message called it.
327
328        :param id: the identifier read off the message
329        :returns: the key
330        :raises FatalError: when the ring does not hold it. Fatal rather than
331            retryable, because the same bytes will name the same missing key on
332            every attempt and retrying only holds the queue open until the
333            message ages out
334        """
335        key = self._keys.get(id)
336        if key is None:
337            raise FatalError(
338                f"acemq: this message was encrypted with key {id!r}, which is not on this "
339                f"keyring (it holds {', '.join(self._keys) or 'nothing'}). Either the key was "
340                "retired while messages written with it were still queued, or the message came "
341                "from a service using a different keyring"
342            )
343        return key
344
345    def __repr__(self) -> str:
346        return f"Keyring(current={self._current!r}, holds={list(self._keys)!r})"
347
348
349def frame(key_id: str) -> bytes:
350    """The header a message written with ``key_id`` carries.
351
352    Magic, version, one length byte and the identifier in UTF-8 — the bytes
353    ahead of the nonce, and the bytes GCM authenticates. Public because a test
354    that wants to prove the framing has to be able to build it, and because a
355    tool reading a queue may want to recognise it.
356
357    :param key_id: the identifier to frame
358    :returns: the header bytes
359    """
360    encoded = key_id.encode("utf-8")
361    return bytes((MAGIC, VERSION, len(encoded))) + encoded
362
363
364def key_id_of(body: bytes) -> str | None:
365    """Reads which key a message needs, without needing the key.
366
367    For the operator looking at a dead-letter queue they can no longer read. The
368    identifier is in the clear in front of the ciphertext, so this answers "which
369    key does this need?" from the bytes alone — which is usually the question,
370    because a queue full of undecryptable messages is normally a key that was
371    retired too early rather than anything wrong with the messages.
372
373    :param body: a message body
374    :returns: the key identifier, or ``None`` when this was not written by this
375        codec
376    """
377    if len(body) < 3 or body[0] != MAGIC or body[1] != VERSION:
378        return None
379    length = body[2]
380    if length == 0 or len(body) < 3 + length:
381        return None
382    try:
383        return body[3 : 3 + length].decode("utf-8")
384    except UnicodeDecodeError:
385        return None
386
387
388class EncryptedCodec:
389    """Encrypts what another codec produced, and decrypts before handing it back.
390
391    :param delegate: the codec that turns objects into bytes; those bytes are
392        what gets encrypted
393    :param keyring: the keys to write with and read with
394    :raises AceMQError: when ``cryptography`` is not installed
395    """
396
397    def __init__(self, delegate: Codec, keyring: Keyring) -> None:
398        try:
399            from cryptography.hazmat.primitives.ciphers.aead import AESGCM
400        except ImportError as missing:  # pragma: no cover - depends on the install
401            raise AceMQError(
402                "acemq: EncryptedCodec needs cryptography, which is an optional extra. "
403                'Install it with pip install "acemq-amqp[crypto]"'
404            ) from missing
405
406        # AES and GCM come from cryptography, which wraps OpenSSL. Writing
407        # either by hand is how a side channel gets into a message path, and
408        # there is no version of that decision that ends well.
409        self._aesgcm: Any = AESGCM
410        self._delegate = delegate
411        self._keyring = keyring
412
413    @property
414    def content_type(self) -> str:
415        return ENCRYPTED_CONTENT_TYPE
416
417    @property
418    def delegate(self) -> Codec:
419        """The wrapped codec, whose output is what gets encrypted.
420
421        Its content type is not visible on the wire, because saying "this is
422        encrypted JSON" tells an observer more than they need. A consumer knows
423        what to expect from its own configuration.
424        """
425        return self._delegate
426
427    def encode(self, payload: Any) -> bytes:
428        key = self._keyring.current
429        header = frame(key.id)
430
431        # A fresh nonce per message, from the operating system. Reusing one
432        # under GCM does not weaken the encryption, it forfeits it: two messages
433        # under the same key and nonce leak their difference outright, and a
434        # counter that restarts with the process — which every counter in a
435        # container eventually does — reuses them by design.
436        nonce = os.urandom(NONCE_BYTES)
437
438        plaintext = self._delegate.encode(payload)
439        try:
440            sealed: bytes = self._aesgcm(key.key).encrypt(nonce, plaintext, header)
441        except Exception as failure:
442            # Without the payload, and without the key. An exception that
443            # helpfully printed what could not be encrypted would write the
444            # plaintext to the log, which is the one place it was never supposed
445            # to reach. The type of the failure is all that comes out.
446            raise AceMQError(
447                f"acemq: could not encrypt a {type(payload).__name__} with key "
448                f"{key.id!r}: {type(failure).__name__}"
449            ) from None
450        return header + nonce + sealed
451
452    def decode(self, body: bytes, content_type: str | None = None) -> Any:
453        key_id = key_id_of(body)
454        if key_id is None:
455            raise FatalError(
456                "acemq: this message was not written by EncryptedCodec — it does not "
457                "start with the framing this codec writes. A consumer configured to "
458                "decrypt has been pointed at a queue carrying something else"
459            )
460
461        header_length = 3 + len(key_id.encode("utf-8"))
462        if len(body) < header_length + NONCE_BYTES + TAG_BYTES:
463            raise FatalError(
464                "acemq: this message is too short to hold a nonce and an authentication "
465                "tag, so it was truncated somewhere between being written and being read"
466            )
467
468        key = self._keyring.key_for(key_id)
469        header = body[:header_length]
470        nonce = body[header_length : header_length + NONCE_BYTES]
471        sealed = body[header_length + NONCE_BYTES :]
472
473        try:
474            plaintext: bytes = self._aesgcm(key.key).decrypt(nonce, sealed, header)
475        except Exception:
476            # One answer for every way this can fail. GCM authenticates as well
477            # as encrypts, so a wrong key and a tampered ciphertext are the same
478            # event here, and they are told apart by nothing — no separate
479            # message, no separate type, no earlier check that would have
480            # distinguished them. An error that said which one it was would be a
481            # padding oracle with better manners.
482            raise FatalError(
483                f"acemq: this message did not decrypt with key {key_id!r}. Either that is "
484                "not the key it was written with, or it was altered after it was written"
485            ) from None
486
487        return self._delegate.decode(plaintext, self._delegate.content_type)
488
489    def can_decode(self, content_type: str | None) -> bool:
490        """Accepts only its own content type.
491
492        Volunteering for anything else means trying to decrypt plaintext and
493        reporting the failure as a decode error, which sends whoever is
494        debugging it in precisely the wrong direction. Never a message with no
495        content type either, for the same reason.
496        """
497        if not content_type:
498            return False
499        return content_type.lower().startswith(ENCRYPTED_CONTENT_TYPE)
500
501    def __repr__(self) -> str:
502        """Names the codec and the current key, and shows neither key nor body."""
503        return f"EncryptedCodec({self._delegate!r}, key={self._keyring.current.id!r})"
ENCRYPTED_CONTENT_TYPE = 'application/vnd.acemq.encrypted'
MAGIC = 174
VERSION = 1
NONCE_BYTES = 12
TAG_BYTES = 16
MAX_KEY_ID_BYTES = 255
KEY_SIZES = (16, 24, 32)
def generate_key() -> bytes:
147def generate_key() -> bytes:
148    """Draws a fresh 32-byte key from the operating system's random source.
149
150    :returns: a new AES-256 key
151    """
152    return os.urandom(32)

Draws a fresh 32-byte key from the operating system's random source.

:returns: a new AES-256 key

def key_from_base64(encoded: str) -> bytes:
155def key_from_base64(encoded: str) -> bytes:
156    """Reads a key out of Base64, which is how one travels in configuration.
157
158    :param encoded: the Base64 text, with surrounding whitespace ignored
159    :returns: the key bytes
160    :raises AceMQError: when the text is not Base64, or does not decode to a
161        length AES takes. The message never quotes the input, because the input
162        is a key
163    """
164    try:
165        key = base64.b64decode(encoded.strip(), validate=True)
166    except (ValueError, TypeError) as failure:
167        # Deliberately not echoing ``encoded``: it is the key, and an exception
168        # is the one place it must never be written down.
169        raise AceMQError(
170            "acemq: this is not Base64, so no key could be read from it: "
171            f"{type(failure).__name__}"
172        ) from None
173    _check_key_size(key)
174    return key

Reads a key out of Base64, which is how one travels in configuration.

Parameters
  • encoded: the Base64 text, with surrounding whitespace ignored :returns: the key bytes
Raises
  • AceMQError: when the text is not Base64, or does not decode to a length AES takes. The message never quotes the input, because the input is a key
def key_to_base64(key: bytes) -> str:
177def key_to_base64(key: bytes) -> str:
178    """Renders a key as Base64.
179
180    The inverse of :func:`key_from_base64`, for writing a generated key into a
181    secret store. Whatever this returns is the key itself, so it belongs in the
182    same place the database password does and nowhere else.
183
184    :param key: the key bytes
185    :returns: the Base64 text
186    """
187    return base64.b64encode(key).decode("ascii")

Renders a key as Base64.

The inverse of key_from_base64(), for writing a generated key into a secret store. Whatever this returns is the key itself, so it belongs in the same place the database password does and nowhere else.

Parameters
  • key: the key bytes :returns: the Base64 text
class EncryptionKey:
199class EncryptionKey:
200    """One key, and the name a message calls it by.
201
202    :param id: what the key is called on the wire. Something datelike —
203        ``2026-01`` — reads well when somebody is working out what to rotate.
204        At most 255 UTF-8 bytes, because it travels in front of every message
205    :param key: 16, 24 or 32 bytes of key material
206    :raises AceMQError: when the identifier is empty or too long, or the key is
207        not a length AES takes
208    """
209
210    __slots__ = ("_id", "_key")
211
212    def __init__(self, id: str, key: bytes) -> None:
213        if not id:
214            raise AceMQError(
215                "acemq: a key identifier cannot be empty: it is what a reader looks "
216                "the key up by"
217            )
218        encoded = id.encode("utf-8")
219        if len(encoded) > MAX_KEY_ID_BYTES:
220            raise AceMQError(
221                f"acemq: a key identifier is at most {MAX_KEY_ID_BYTES} bytes and {id!r} is "
222                f"{len(encoded)}. It travels in front of every message, so it is a name rather "
223                "than a description"
224            )
225        if "\x00" in id:
226            raise AceMQError(f"acemq: the key identifier {id!r} contains a null byte")
227        _check_key_size(key)
228        self._id = id
229        self._key = bytes(key)
230
231    @property
232    def id(self) -> str:
233        """What a message calls this key."""
234        return self._id
235
236    @property
237    def key(self) -> bytes:
238        """The key material."""
239        return self._key
240
241    def __eq__(self, other: object) -> bool:
242        if not isinstance(other, EncryptionKey):
243            return NotImplemented
244        return self._id == other._id and self._key == other._key
245
246    def __hash__(self) -> int:
247        return hash((self._id, self._key))
248
249    def __repr__(self) -> str:
250        """Names the key and never shows it.
251
252        A key logged by accident — in a dataclass dump, in an exception, in a
253        debugger's variables pane — prints its identifier and nothing else. That
254        is the whole reason this is a type rather than a tuple, and a test pins
255        it.
256        """
257        return f"EncryptionKey(id={self._id!r})"
258
259    __str__ = __repr__

One key, and the name a message calls it by.

Parameters
  • id: what the key is called on the wire. Something datelike — 2026-01 — reads well when somebody is working out what to rotate. At most 255 UTF-8 bytes, because it travels in front of every message
  • key: 16, 24 or 32 bytes of key material
Raises
  • AceMQError: when the identifier is empty or too long, or the key is not a length AES takes
EncryptionKey(id: str, key: bytes)
212    def __init__(self, id: str, key: bytes) -> None:
213        if not id:
214            raise AceMQError(
215                "acemq: a key identifier cannot be empty: it is what a reader looks "
216                "the key up by"
217            )
218        encoded = id.encode("utf-8")
219        if len(encoded) > MAX_KEY_ID_BYTES:
220            raise AceMQError(
221                f"acemq: a key identifier is at most {MAX_KEY_ID_BYTES} bytes and {id!r} is "
222                f"{len(encoded)}. It travels in front of every message, so it is a name rather "
223                "than a description"
224            )
225        if "\x00" in id:
226            raise AceMQError(f"acemq: the key identifier {id!r} contains a null byte")
227        _check_key_size(key)
228        self._id = id
229        self._key = bytes(key)
id: str
231    @property
232    def id(self) -> str:
233        """What a message calls this key."""
234        return self._id

What a message calls this key.

key: bytes
236    @property
237    def key(self) -> bytes:
238        """The key material."""
239        return self._key

The key material.

class Keyring:
262class Keyring:
263    """The keys a process can use, and which one it writes with.
264
265    More than one, because rotation needs an overlap: new messages are written
266    with the newest key while messages written with the old one are still being
267    read. A keyring with one key cannot rotate without an outage, and the order
268    to do it in is *add the new key everywhere first*, so every consumer can read
269    it, and only then :meth:`use` it somewhere.
270
271    :param keys: the keys to hold. The first is the one written with, unless
272        :meth:`use` says otherwise
273    :raises AceMQError: when given no keys at all
274    """
275
276    __slots__ = ("_current", "_keys")
277
278    def __init__(self, *keys: EncryptionKey) -> None:
279        if not keys:
280            raise AceMQError(
281                "acemq: a keyring needs at least one key; there is nothing to write with"
282            )
283        self._keys: dict[str, EncryptionKey] = {}
284        for key in keys:
285            self._keys[key.id] = key
286        self._current = keys[0].id
287
288    @classmethod
289    def of(cls, id: str, key: bytes) -> Keyring:
290        """A keyring holding one key, which is where every rotation starts.
291
292        :param id: what the key is called on the wire
293        :param key: the key material
294        :returns: the keyring
295        """
296        return cls(EncryptionKey(id, key))
297
298    def add(self, key: EncryptionKey) -> None:
299        """Puts a key on the ring without making it the one written with.
300
301        :param key: the key to hold
302        """
303        self._keys[key.id] = key
304
305    def use(self, id: str) -> None:
306        """Makes a key the one new messages are written with.
307
308        :param id: which key
309        :raises AceMQError: when that key is not on the ring
310        """
311        if id not in self._keys:
312            raise AceMQError(f"acemq: there is no key {id!r} on this keyring")
313        self._current = id
314
315    @property
316    def current(self) -> EncryptionKey:
317        """The key new messages are written with."""
318        return self._keys[self._current]
319
320    @property
321    def ids(self) -> tuple[str, ...]:
322        """The keys on the ring, in the order they were added. Never the keys
323        themselves — this is for a health endpoint or a line at start-up."""
324        return tuple(self._keys)
325
326    def key_for(self, id: str) -> EncryptionKey:
327        """Looks a key up by the name a message called it.
328
329        :param id: the identifier read off the message
330        :returns: the key
331        :raises FatalError: when the ring does not hold it. Fatal rather than
332            retryable, because the same bytes will name the same missing key on
333            every attempt and retrying only holds the queue open until the
334            message ages out
335        """
336        key = self._keys.get(id)
337        if key is None:
338            raise FatalError(
339                f"acemq: this message was encrypted with key {id!r}, which is not on this "
340                f"keyring (it holds {', '.join(self._keys) or 'nothing'}). Either the key was "
341                "retired while messages written with it were still queued, or the message came "
342                "from a service using a different keyring"
343            )
344        return key
345
346    def __repr__(self) -> str:
347        return f"Keyring(current={self._current!r}, holds={list(self._keys)!r})"

The keys a process can use, and which one it writes with.

More than one, because rotation needs an overlap: new messages are written with the newest key while messages written with the old one are still being read. A keyring with one key cannot rotate without an outage, and the order to do it in is add the new key everywhere first, so every consumer can read it, and only then use() it somewhere.

Parameters
  • keys: the keys to hold. The first is the one written with, unless use() says otherwise
Raises
  • AceMQError: when given no keys at all
Keyring(*keys: EncryptionKey)
278    def __init__(self, *keys: EncryptionKey) -> None:
279        if not keys:
280            raise AceMQError(
281                "acemq: a keyring needs at least one key; there is nothing to write with"
282            )
283        self._keys: dict[str, EncryptionKey] = {}
284        for key in keys:
285            self._keys[key.id] = key
286        self._current = keys[0].id
@classmethod
def of(cls, id: str, key: bytes) -> Keyring:
288    @classmethod
289    def of(cls, id: str, key: bytes) -> Keyring:
290        """A keyring holding one key, which is where every rotation starts.
291
292        :param id: what the key is called on the wire
293        :param key: the key material
294        :returns: the keyring
295        """
296        return cls(EncryptionKey(id, key))

A keyring holding one key, which is where every rotation starts.

Parameters
  • id: what the key is called on the wire
  • key: the key material :returns: the keyring
def add(self, key: EncryptionKey) -> None:
298    def add(self, key: EncryptionKey) -> None:
299        """Puts a key on the ring without making it the one written with.
300
301        :param key: the key to hold
302        """
303        self._keys[key.id] = key

Puts a key on the ring without making it the one written with.

Parameters
  • key: the key to hold
def use(self, id: str) -> None:
305    def use(self, id: str) -> None:
306        """Makes a key the one new messages are written with.
307
308        :param id: which key
309        :raises AceMQError: when that key is not on the ring
310        """
311        if id not in self._keys:
312            raise AceMQError(f"acemq: there is no key {id!r} on this keyring")
313        self._current = id

Makes a key the one new messages are written with.

Parameters
  • id: which key
Raises
  • AceMQError: when that key is not on the ring
current: EncryptionKey
315    @property
316    def current(self) -> EncryptionKey:
317        """The key new messages are written with."""
318        return self._keys[self._current]

The key new messages are written with.

ids: tuple[str, ...]
320    @property
321    def ids(self) -> tuple[str, ...]:
322        """The keys on the ring, in the order they were added. Never the keys
323        themselves — this is for a health endpoint or a line at start-up."""
324        return tuple(self._keys)

The keys on the ring, in the order they were added. Never the keys themselves — this is for a health endpoint or a line at start-up.

def key_for(self, id: str) -> EncryptionKey:
326    def key_for(self, id: str) -> EncryptionKey:
327        """Looks a key up by the name a message called it.
328
329        :param id: the identifier read off the message
330        :returns: the key
331        :raises FatalError: when the ring does not hold it. Fatal rather than
332            retryable, because the same bytes will name the same missing key on
333            every attempt and retrying only holds the queue open until the
334            message ages out
335        """
336        key = self._keys.get(id)
337        if key is None:
338            raise FatalError(
339                f"acemq: this message was encrypted with key {id!r}, which is not on this "
340                f"keyring (it holds {', '.join(self._keys) or 'nothing'}). Either the key was "
341                "retired while messages written with it were still queued, or the message came "
342                "from a service using a different keyring"
343            )
344        return key

Looks a key up by the name a message called it.

Parameters
  • id: the identifier read off the message :returns: the key
Raises
  • FatalError: when the ring does not hold it. Fatal rather than retryable, because the same bytes will name the same missing key on every attempt and retrying only holds the queue open until the message ages out
def frame(key_id: str) -> bytes:
350def frame(key_id: str) -> bytes:
351    """The header a message written with ``key_id`` carries.
352
353    Magic, version, one length byte and the identifier in UTF-8 — the bytes
354    ahead of the nonce, and the bytes GCM authenticates. Public because a test
355    that wants to prove the framing has to be able to build it, and because a
356    tool reading a queue may want to recognise it.
357
358    :param key_id: the identifier to frame
359    :returns: the header bytes
360    """
361    encoded = key_id.encode("utf-8")
362    return bytes((MAGIC, VERSION, len(encoded))) + encoded

The header a message written with key_id carries.

Magic, version, one length byte and the identifier in UTF-8 — the bytes ahead of the nonce, and the bytes GCM authenticates. Public because a test that wants to prove the framing has to be able to build it, and because a tool reading a queue may want to recognise it.

Parameters
  • key_id: the identifier to frame :returns: the header bytes
def key_id_of(body: bytes) -> str | None:
365def key_id_of(body: bytes) -> str | None:
366    """Reads which key a message needs, without needing the key.
367
368    For the operator looking at a dead-letter queue they can no longer read. The
369    identifier is in the clear in front of the ciphertext, so this answers "which
370    key does this need?" from the bytes alone — which is usually the question,
371    because a queue full of undecryptable messages is normally a key that was
372    retired too early rather than anything wrong with the messages.
373
374    :param body: a message body
375    :returns: the key identifier, or ``None`` when this was not written by this
376        codec
377    """
378    if len(body) < 3 or body[0] != MAGIC or body[1] != VERSION:
379        return None
380    length = body[2]
381    if length == 0 or len(body) < 3 + length:
382        return None
383    try:
384        return body[3 : 3 + length].decode("utf-8")
385    except UnicodeDecodeError:
386        return None

Reads which key a message needs, without needing the key.

For the operator looking at a dead-letter queue they can no longer read. The identifier is in the clear in front of the ciphertext, so this answers "which key does this need?" from the bytes alone — which is usually the question, because a queue full of undecryptable messages is normally a key that was retired too early rather than anything wrong with the messages.

Parameters
  • body: a message body :returns: the key identifier, or None when this was not written by this codec
class EncryptedCodec:
389class EncryptedCodec:
390    """Encrypts what another codec produced, and decrypts before handing it back.
391
392    :param delegate: the codec that turns objects into bytes; those bytes are
393        what gets encrypted
394    :param keyring: the keys to write with and read with
395    :raises AceMQError: when ``cryptography`` is not installed
396    """
397
398    def __init__(self, delegate: Codec, keyring: Keyring) -> None:
399        try:
400            from cryptography.hazmat.primitives.ciphers.aead import AESGCM
401        except ImportError as missing:  # pragma: no cover - depends on the install
402            raise AceMQError(
403                "acemq: EncryptedCodec needs cryptography, which is an optional extra. "
404                'Install it with pip install "acemq-amqp[crypto]"'
405            ) from missing
406
407        # AES and GCM come from cryptography, which wraps OpenSSL. Writing
408        # either by hand is how a side channel gets into a message path, and
409        # there is no version of that decision that ends well.
410        self._aesgcm: Any = AESGCM
411        self._delegate = delegate
412        self._keyring = keyring
413
414    @property
415    def content_type(self) -> str:
416        return ENCRYPTED_CONTENT_TYPE
417
418    @property
419    def delegate(self) -> Codec:
420        """The wrapped codec, whose output is what gets encrypted.
421
422        Its content type is not visible on the wire, because saying "this is
423        encrypted JSON" tells an observer more than they need. A consumer knows
424        what to expect from its own configuration.
425        """
426        return self._delegate
427
428    def encode(self, payload: Any) -> bytes:
429        key = self._keyring.current
430        header = frame(key.id)
431
432        # A fresh nonce per message, from the operating system. Reusing one
433        # under GCM does not weaken the encryption, it forfeits it: two messages
434        # under the same key and nonce leak their difference outright, and a
435        # counter that restarts with the process — which every counter in a
436        # container eventually does — reuses them by design.
437        nonce = os.urandom(NONCE_BYTES)
438
439        plaintext = self._delegate.encode(payload)
440        try:
441            sealed: bytes = self._aesgcm(key.key).encrypt(nonce, plaintext, header)
442        except Exception as failure:
443            # Without the payload, and without the key. An exception that
444            # helpfully printed what could not be encrypted would write the
445            # plaintext to the log, which is the one place it was never supposed
446            # to reach. The type of the failure is all that comes out.
447            raise AceMQError(
448                f"acemq: could not encrypt a {type(payload).__name__} with key "
449                f"{key.id!r}: {type(failure).__name__}"
450            ) from None
451        return header + nonce + sealed
452
453    def decode(self, body: bytes, content_type: str | None = None) -> Any:
454        key_id = key_id_of(body)
455        if key_id is None:
456            raise FatalError(
457                "acemq: this message was not written by EncryptedCodec — it does not "
458                "start with the framing this codec writes. A consumer configured to "
459                "decrypt has been pointed at a queue carrying something else"
460            )
461
462        header_length = 3 + len(key_id.encode("utf-8"))
463        if len(body) < header_length + NONCE_BYTES + TAG_BYTES:
464            raise FatalError(
465                "acemq: this message is too short to hold a nonce and an authentication "
466                "tag, so it was truncated somewhere between being written and being read"
467            )
468
469        key = self._keyring.key_for(key_id)
470        header = body[:header_length]
471        nonce = body[header_length : header_length + NONCE_BYTES]
472        sealed = body[header_length + NONCE_BYTES :]
473
474        try:
475            plaintext: bytes = self._aesgcm(key.key).decrypt(nonce, sealed, header)
476        except Exception:
477            # One answer for every way this can fail. GCM authenticates as well
478            # as encrypts, so a wrong key and a tampered ciphertext are the same
479            # event here, and they are told apart by nothing — no separate
480            # message, no separate type, no earlier check that would have
481            # distinguished them. An error that said which one it was would be a
482            # padding oracle with better manners.
483            raise FatalError(
484                f"acemq: this message did not decrypt with key {key_id!r}. Either that is "
485                "not the key it was written with, or it was altered after it was written"
486            ) from None
487
488        return self._delegate.decode(plaintext, self._delegate.content_type)
489
490    def can_decode(self, content_type: str | None) -> bool:
491        """Accepts only its own content type.
492
493        Volunteering for anything else means trying to decrypt plaintext and
494        reporting the failure as a decode error, which sends whoever is
495        debugging it in precisely the wrong direction. Never a message with no
496        content type either, for the same reason.
497        """
498        if not content_type:
499            return False
500        return content_type.lower().startswith(ENCRYPTED_CONTENT_TYPE)
501
502    def __repr__(self) -> str:
503        """Names the codec and the current key, and shows neither key nor body."""
504        return f"EncryptedCodec({self._delegate!r}, key={self._keyring.current.id!r})"

Encrypts what another codec produced, and decrypts before handing it back.

Parameters
  • delegate: the codec that turns objects into bytes; those bytes are what gets encrypted
  • keyring: the keys to write with and read with
Raises
  • AceMQError: when cryptography is not installed
EncryptedCodec( delegate: acemq_amqp.Codec, keyring: Keyring)
398    def __init__(self, delegate: Codec, keyring: Keyring) -> None:
399        try:
400            from cryptography.hazmat.primitives.ciphers.aead import AESGCM
401        except ImportError as missing:  # pragma: no cover - depends on the install
402            raise AceMQError(
403                "acemq: EncryptedCodec needs cryptography, which is an optional extra. "
404                'Install it with pip install "acemq-amqp[crypto]"'
405            ) from missing
406
407        # AES and GCM come from cryptography, which wraps OpenSSL. Writing
408        # either by hand is how a side channel gets into a message path, and
409        # there is no version of that decision that ends well.
410        self._aesgcm: Any = AESGCM
411        self._delegate = delegate
412        self._keyring = keyring
content_type: str
414    @property
415    def content_type(self) -> str:
416        return ENCRYPTED_CONTENT_TYPE
delegate: acemq_amqp.Codec
418    @property
419    def delegate(self) -> Codec:
420        """The wrapped codec, whose output is what gets encrypted.
421
422        Its content type is not visible on the wire, because saying "this is
423        encrypted JSON" tells an observer more than they need. A consumer knows
424        what to expect from its own configuration.
425        """
426        return self._delegate

The wrapped codec, whose output is what gets encrypted.

Its content type is not visible on the wire, because saying "this is encrypted JSON" tells an observer more than they need. A consumer knows what to expect from its own configuration.

def encode(self, payload: Any) -> bytes:
428    def encode(self, payload: Any) -> bytes:
429        key = self._keyring.current
430        header = frame(key.id)
431
432        # A fresh nonce per message, from the operating system. Reusing one
433        # under GCM does not weaken the encryption, it forfeits it: two messages
434        # under the same key and nonce leak their difference outright, and a
435        # counter that restarts with the process — which every counter in a
436        # container eventually does — reuses them by design.
437        nonce = os.urandom(NONCE_BYTES)
438
439        plaintext = self._delegate.encode(payload)
440        try:
441            sealed: bytes = self._aesgcm(key.key).encrypt(nonce, plaintext, header)
442        except Exception as failure:
443            # Without the payload, and without the key. An exception that
444            # helpfully printed what could not be encrypted would write the
445            # plaintext to the log, which is the one place it was never supposed
446            # to reach. The type of the failure is all that comes out.
447            raise AceMQError(
448                f"acemq: could not encrypt a {type(payload).__name__} with key "
449                f"{key.id!r}: {type(failure).__name__}"
450            ) from None
451        return header + nonce + sealed
def decode(self, body: bytes, content_type: str | None = None) -> Any:
453    def decode(self, body: bytes, content_type: str | None = None) -> Any:
454        key_id = key_id_of(body)
455        if key_id is None:
456            raise FatalError(
457                "acemq: this message was not written by EncryptedCodec — it does not "
458                "start with the framing this codec writes. A consumer configured to "
459                "decrypt has been pointed at a queue carrying something else"
460            )
461
462        header_length = 3 + len(key_id.encode("utf-8"))
463        if len(body) < header_length + NONCE_BYTES + TAG_BYTES:
464            raise FatalError(
465                "acemq: this message is too short to hold a nonce and an authentication "
466                "tag, so it was truncated somewhere between being written and being read"
467            )
468
469        key = self._keyring.key_for(key_id)
470        header = body[:header_length]
471        nonce = body[header_length : header_length + NONCE_BYTES]
472        sealed = body[header_length + NONCE_BYTES :]
473
474        try:
475            plaintext: bytes = self._aesgcm(key.key).decrypt(nonce, sealed, header)
476        except Exception:
477            # One answer for every way this can fail. GCM authenticates as well
478            # as encrypts, so a wrong key and a tampered ciphertext are the same
479            # event here, and they are told apart by nothing — no separate
480            # message, no separate type, no earlier check that would have
481            # distinguished them. An error that said which one it was would be a
482            # padding oracle with better manners.
483            raise FatalError(
484                f"acemq: this message did not decrypt with key {key_id!r}. Either that is "
485                "not the key it was written with, or it was altered after it was written"
486            ) from None
487
488        return self._delegate.decode(plaintext, self._delegate.content_type)
def can_decode(self, content_type: str | None) -> bool:
490    def can_decode(self, content_type: str | None) -> bool:
491        """Accepts only its own content type.
492
493        Volunteering for anything else means trying to decrypt plaintext and
494        reporting the failure as a decode error, which sends whoever is
495        debugging it in precisely the wrong direction. Never a message with no
496        content type either, for the same reason.
497        """
498        if not content_type:
499            return False
500        return content_type.lower().startswith(ENCRYPTED_CONTENT_TYPE)

Accepts only its own content type.

Volunteering for anything else means trying to decrypt plaintext and reporting the failure as a decode error, which sends whoever is debugging it in precisely the wrong direction. Never a message with no content type either, for the same reason.