rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""Cryptographic message signing middleware.
|
|
2
|
+
|
|
3
|
+
Signs outgoing messages with **HMAC** (SHA-256 or SHA-512) and verifies
|
|
4
|
+
incoming signatures. Uses stdlib ``hmac`` + ``hashlib`` — no extra deps.
|
|
5
|
+
|
|
6
|
+
How it works
|
|
7
|
+
------------
|
|
8
|
+
*Publish path*: Before a message is sent, ``SigningMiddleware`` computes a
|
|
9
|
+
replay-protected, route-bound HMAC and stores the hex digest in an AMQP
|
|
10
|
+
header (default: ``x-rabbitkit-signature``), alongside
|
|
11
|
+
``x-rabbitkit-sign-timestamp`` and ``x-rabbitkit-sign-nonce`` headers.
|
|
12
|
+
|
|
13
|
+
*Consume path*: On receipt the middleware reads the signature header and
|
|
14
|
+
calls ``hmac.compare_digest`` (constant-time) to verify it. When freshness
|
|
15
|
+
headers are present the timestamp skew AND a server-side nonce seen-set are
|
|
16
|
+
enforced to defeat replay attacks. Behaviour when verification fails is
|
|
17
|
+
configurable:
|
|
18
|
+
|
|
19
|
+
* ``reject_invalid=True`` (default) — raise ``InvalidSignatureError``
|
|
20
|
+
* ``reject_unsigned=True`` — raise ``InvalidSignatureError`` if the header is absent
|
|
21
|
+
* Both ``False`` — log / pass unsigned/invalid messages through (monitoring mode)
|
|
22
|
+
|
|
23
|
+
What the signature covers (H3)
|
|
24
|
+
-------------------------------
|
|
25
|
+
The **fresh** (replay-protected, ``require_freshness=True`` default) signature
|
|
26
|
+
is an HMAC over::
|
|
27
|
+
|
|
28
|
+
timestamp:nonce: + exchange \\x00 routing_key \\x00 content_encoding \\x00 reply_to \\x00 + body
|
|
29
|
+
|
|
30
|
+
i.e. ``timestamp``, ``nonce``, ``exchange``, ``routing_key``,
|
|
31
|
+
``content_encoding``, ``reply_to``, and ``body`` — computed at publish time
|
|
32
|
+
from the outgoing ``MessageEnvelope`` and re-derived at consume time from the
|
|
33
|
+
*delivered* ``RabbitMessage``'s broker-reported routing metadata (not from
|
|
34
|
+
attacker-controlled headers). Changing any of those fields on a captured
|
|
35
|
+
message — re-publishing it under a different routing key, redirecting an RPC
|
|
36
|
+
reply via ``reply_to``, or flipping ``content_encoding`` to hit a different
|
|
37
|
+
decompression path — invalidates the signature even though the body,
|
|
38
|
+
timestamp, and nonce are all unchanged.
|
|
39
|
+
|
|
40
|
+
**Not covered**: any header other than the signature/timestamp/nonce triplet
|
|
41
|
+
itself. Do not use freeform headers for security-critical routing or
|
|
42
|
+
dispatch decisions under this middleware — they are not authenticated.
|
|
43
|
+
|
|
44
|
+
The **legacy** body-only signature (only reachable with
|
|
45
|
+
``require_freshness=False``, for interop with producers that predate the
|
|
46
|
+
freshness headers) covers ``body`` ONLY — no routing metadata, no replay
|
|
47
|
+
protection. It exists solely for backward compatibility with signers this
|
|
48
|
+
library does not control; prefer the default ``require_freshness=True`` for
|
|
49
|
+
any security-sensitive deployment.
|
|
50
|
+
|
|
51
|
+
Combining with CompressionMiddleware (H7)
|
|
52
|
+
------------------------------------------
|
|
53
|
+
Use ``middlewares=[CompressionMiddleware(...), SigningMiddleware(...)]`` —
|
|
54
|
+
compression OUTER, signing INNER. This order is required, not a suggestion:
|
|
55
|
+
the signature covers ``content_encoding`` (see above), a field
|
|
56
|
+
``CompressionMiddleware``'s ``publish_scope`` is what actually sets. With
|
|
57
|
+
signing outer (the reverse order), signing would sign
|
|
58
|
+
``content_encoding=None`` (unset at that point) while compression sets it to
|
|
59
|
+
e.g. ``"gzip"`` afterward — the delivered message's ``content_encoding``
|
|
60
|
+
then never matches what was signed, and verification fails
|
|
61
|
+
unconditionally. With the correct order, compression sets
|
|
62
|
+
``content_encoding`` first, signing signs the final value and the compressed
|
|
63
|
+
body, and ``HandlerPipeline`` runs ``on_receive`` hooks in the REVERSE of
|
|
64
|
+
registration order on consume (verify before decompress, mirroring
|
|
65
|
+
compress-then-sign on publish) so the two compose correctly end-to-end. A
|
|
66
|
+
signature/decompression failure in ``on_receive`` is NOT retry-eligible (see
|
|
67
|
+
``HandlerPipeline._run_consume_sync``'s docstring) — it settles per the
|
|
68
|
+
route's ``AckPolicy`` directly, bypassing any ``RetryMiddleware`` on the
|
|
69
|
+
route.
|
|
70
|
+
|
|
71
|
+
Replay protection
|
|
72
|
+
-----------------
|
|
73
|
+
``require_freshness`` defaults to ``True``. The consume-time rules are:
|
|
74
|
+
|
|
75
|
+
* **Freshness headers present** (new producer): skew (``abs(now - ts) <= max_skew``,
|
|
76
|
+
both past and future) and the nonce seen-set are **always** enforced regardless
|
|
77
|
+
of ``require_freshness``. A stale timestamp or a duplicate nonce raises
|
|
78
|
+
``InvalidSignatureError`` (permanent — no retry).
|
|
79
|
+
* **Freshness headers absent + ``require_freshness=True``**: rejected (strict).
|
|
80
|
+
* **Freshness headers absent + ``require_freshness=False``**: the legacy
|
|
81
|
+
body-only signature is verified and a warning is logged (backward compat
|
|
82
|
+
with old producers).
|
|
83
|
+
|
|
84
|
+
The nonce seen-set is pluggable via the ``NonceCache`` protocol; a default
|
|
85
|
+
in-memory ``TTLSetNonceCache`` is used when none is supplied so replay
|
|
86
|
+
protection works out of the box **for a single process**.
|
|
87
|
+
|
|
88
|
+
Shared nonce store for multi-process/multi-pod deployments (H4)
|
|
89
|
+
-----------------------------------------------------------------
|
|
90
|
+
``TTLSetNonceCache`` is a plain in-process dict. In any multi-process or
|
|
91
|
+
multi-pod deployment — the normal case for a consumer with more than one
|
|
92
|
+
replica — a nonce recorded by one process is invisible to every other
|
|
93
|
+
process, and it is lost entirely on restart. A replay that happens to land on
|
|
94
|
+
a *different* worker than the original message passes the nonce check even
|
|
95
|
+
though the exact same signed payload was already processed elsewhere.
|
|
96
|
+
``SigningMiddleware`` warns at construction time (``RuntimeWarning``) when
|
|
97
|
+
``require_freshness=True`` and no explicit ``nonce_cache`` was supplied, for
|
|
98
|
+
exactly this reason.
|
|
99
|
+
|
|
100
|
+
Use :class:`RedisNonceCache` (or your own ``NonceCache`` implementation) to
|
|
101
|
+
share the seen-set across every process/pod that verifies signatures for the
|
|
102
|
+
same producer::
|
|
103
|
+
|
|
104
|
+
import redis
|
|
105
|
+
from rabbitkit.middleware.signing import RedisNonceCache, SigningConfig
|
|
106
|
+
|
|
107
|
+
cache = RedisNonceCache(redis.Redis(host="redis", port=6379))
|
|
108
|
+
config = SigningConfig(secret_key="shared-secret", nonce_cache=cache)
|
|
109
|
+
|
|
110
|
+
``RedisNonceCache`` records each nonce with an atomic ``SET NX EX`` — two
|
|
111
|
+
processes racing on the same nonce can never both "win" the check, which is
|
|
112
|
+
exactly the guarantee replay protection needs across multiple workers.
|
|
113
|
+
|
|
114
|
+
For payments or other high-value traffic, also consider a tighter
|
|
115
|
+
``max_skew`` than the default (60s) to shrink the replay window, and always
|
|
116
|
+
use a shared ``nonce_cache`` — the in-memory default is not sufficient once
|
|
117
|
+
there is more than one consumer process.
|
|
118
|
+
|
|
119
|
+
Quick start — symmetric signing between two services
|
|
120
|
+
-----------------------------------------------------
|
|
121
|
+
Sender side::
|
|
122
|
+
|
|
123
|
+
from rabbitkit.middleware.signing import SigningMiddleware, SigningConfig
|
|
124
|
+
|
|
125
|
+
signing_mw = SigningMiddleware(
|
|
126
|
+
SigningConfig(secret_key="shared-secret-do-not-commit")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Attach to broker so ALL outgoing publishes are signed
|
|
130
|
+
@broker.publisher(exchange="events", routing_key="order.created")
|
|
131
|
+
@broker.subscriber(queue="orders-input", middlewares=[signing_mw])
|
|
132
|
+
async def process_order(body: bytes) -> bytes:
|
|
133
|
+
return b'{"status": "ok"}'
|
|
134
|
+
|
|
135
|
+
Receiver side (different service, same shared secret)::
|
|
136
|
+
|
|
137
|
+
signing_mw = SigningMiddleware(
|
|
138
|
+
SigningConfig(
|
|
139
|
+
secret_key="shared-secret-do-not-commit",
|
|
140
|
+
reject_unsigned=True, # reject messages without a signature
|
|
141
|
+
reject_invalid=True, # reject messages with wrong signature
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@broker.subscriber(queue="order-results", middlewares=[signing_mw])
|
|
146
|
+
async def handle_result(body: bytes) -> None:
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
Stronger algorithm (SHA-512)::
|
|
150
|
+
|
|
151
|
+
signing_mw = SigningMiddleware(
|
|
152
|
+
SigningConfig(
|
|
153
|
+
secret_key=b"\\x00very\\xff long\\xde random\\xad key",
|
|
154
|
+
algorithm="hmac-sha512",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
Custom header name::
|
|
159
|
+
|
|
160
|
+
SigningConfig(secret_key="s3cr3t", header_name="x-service-sig")
|
|
161
|
+
|
|
162
|
+
Exceptions
|
|
163
|
+
----------
|
|
164
|
+
``InvalidSignatureError`` is raised by ``on_receive`` / ``on_receive_async``
|
|
165
|
+
when verification fails. It is classified as a ``PERMANENT`` error by the
|
|
166
|
+
default error classifier, so retry will not be attempted — the message goes
|
|
167
|
+
straight to the DLQ.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
from __future__ import annotations
|
|
171
|
+
|
|
172
|
+
import hashlib
|
|
173
|
+
import hmac
|
|
174
|
+
import logging
|
|
175
|
+
import math
|
|
176
|
+
import threading
|
|
177
|
+
import time
|
|
178
|
+
import uuid
|
|
179
|
+
from dataclasses import dataclass
|
|
180
|
+
from typing import Any, Protocol
|
|
181
|
+
|
|
182
|
+
from rabbitkit.core.errors import ConfigurationError
|
|
183
|
+
from rabbitkit.core.message import RabbitMessage
|
|
184
|
+
from rabbitkit.core.types import MessageEnvelope
|
|
185
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
186
|
+
|
|
187
|
+
logger = logging.getLogger(__name__)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class InvalidSignatureError(Exception):
|
|
191
|
+
"""Raised when a message signature is invalid."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _const_time_eq(expected: str, signature: str | bytes) -> bool:
|
|
195
|
+
"""Constant-time compare of a hex-digest ``expected`` against ``signature``.
|
|
196
|
+
|
|
197
|
+
``hmac.compare_digest`` requires both args to share a type; normalise the
|
|
198
|
+
signature to bytes so a ``bytes`` signature header compares cleanly against
|
|
199
|
+
the str hex digest without raising ``TypeError``.
|
|
200
|
+
"""
|
|
201
|
+
expected_b = expected.encode("utf-8")
|
|
202
|
+
sig_b = signature.encode("utf-8") if isinstance(signature, str) else signature
|
|
203
|
+
return hmac.compare_digest(expected_b, sig_b)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Replay protection: pluggable nonce seen-set ───────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class NonceCache(Protocol):
|
|
210
|
+
"""A server-side seen-set for replay-protection nonces.
|
|
211
|
+
|
|
212
|
+
``seen(nonce, ttl)`` returns ``True`` when the nonce is first observed
|
|
213
|
+
(and records it for ``ttl`` seconds), and ``False`` when the nonce has
|
|
214
|
+
already been seen and is still within its TTL (i.e. a replay).
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def seen(self, nonce: str, ttl: float) -> bool:
|
|
218
|
+
"""Record/lookup a nonce. True = first-seen/accepted, False = duplicate."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TTLSetNonceCache:
|
|
222
|
+
"""Default in-memory nonce seen-set with TTL eviction.
|
|
223
|
+
|
|
224
|
+
Thread-safe (a single lock guards the dict). Bounded to ``max_entries``
|
|
225
|
+
nonces; when full, expired entries are reclaimed first. Expiry is lazy —
|
|
226
|
+
checked on each lookup and opportunistically during GC.
|
|
227
|
+
|
|
228
|
+
L4: an entry that is still within its TTL (genuinely live — a nonce
|
|
229
|
+
that was legitimately seen recently and remains within the replay
|
|
230
|
+
window) is **never** evicted to make room. Previously, once full, the
|
|
231
|
+
oldest 10% were evicted by insertion order regardless of whether they
|
|
232
|
+
had actually expired — an attacker could flood unique nonces to push a
|
|
233
|
+
target's live entry out of the set, then replay that nonce successfully
|
|
234
|
+
(it looks unseen again once evicted). Now, if the set is still at
|
|
235
|
+
capacity after reclaiming expired entries (i.e. genuinely full of live,
|
|
236
|
+
unexpired nonces — a flood, or ``max_entries`` sized too small for your
|
|
237
|
+
throughput x ``max_skew``), the new nonce is conservatively treated as
|
|
238
|
+
"not first-seen" (rejected) rather than evicting a live entry to make
|
|
239
|
+
room — the caller sees the same outcome as an actual replay (fail
|
|
240
|
+
closed under pressure, never silently let a live entry become
|
|
241
|
+
replayable). Size ``max_entries`` from your expected throughput x
|
|
242
|
+
``max_skew`` to avoid hitting this in normal operation; a shared
|
|
243
|
+
``RedisNonceCache`` sidesteps the bound entirely.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(self, max_entries: int = 100_000) -> None:
|
|
247
|
+
if max_entries <= 0:
|
|
248
|
+
raise ValueError("max_entries must be positive")
|
|
249
|
+
self._entries: dict[str, float] = {}
|
|
250
|
+
self._lock = threading.Lock()
|
|
251
|
+
self._max_entries = max_entries
|
|
252
|
+
|
|
253
|
+
def seen(self, nonce: str, ttl: float) -> bool:
|
|
254
|
+
# monotonic clock — immune to wall-clock adjustments.
|
|
255
|
+
now = time.monotonic()
|
|
256
|
+
expiry = now + ttl
|
|
257
|
+
with self._lock:
|
|
258
|
+
existing = self._entries.get(nonce)
|
|
259
|
+
if existing is not None and existing > now:
|
|
260
|
+
# Already seen and still valid → replay.
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Lazy GC: bound the set size.
|
|
264
|
+
if len(self._entries) >= self._max_entries:
|
|
265
|
+
# Drop expired entries first (cheap-ish pass over the dict).
|
|
266
|
+
for k in [k for k, exp in self._entries.items() if exp <= now]:
|
|
267
|
+
del self._entries[k]
|
|
268
|
+
# L4: still full after reclaiming expired entries means the
|
|
269
|
+
# set is genuinely full of LIVE nonces — never evict one of
|
|
270
|
+
# those to make room (that would let it be replayed once
|
|
271
|
+
# evicted). Reject the new nonce instead (fail closed).
|
|
272
|
+
if len(self._entries) >= self._max_entries:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"TTLSetNonceCache at capacity (%d) with no expired entries to reclaim; "
|
|
275
|
+
"rejecting new nonce rather than evicting a live one. Size max_entries "
|
|
276
|
+
"from throughput x max_skew, or use a shared RedisNonceCache.",
|
|
277
|
+
self._max_entries,
|
|
278
|
+
)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
self._entries[nonce] = expiry
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class RedisNonceCache:
|
|
286
|
+
"""Redis-backed nonce seen-set for replay protection shared across
|
|
287
|
+
multiple processes/pods (H4).
|
|
288
|
+
|
|
289
|
+
``TTLSetNonceCache`` (the default) is per-process, in-memory — see the
|
|
290
|
+
module docstring's "Shared nonce store" section for why that is
|
|
291
|
+
insufficient once there is more than one consumer process. This class
|
|
292
|
+
shares the seen-set across every process pointed at the same Redis
|
|
293
|
+
instance/key prefix using an atomic ``SET NX EX``: two processes racing
|
|
294
|
+
to record the same nonce can never both receive ``True``, which is
|
|
295
|
+
exactly the guarantee needed to catch a replay that lands on a different
|
|
296
|
+
worker than the original message.
|
|
297
|
+
|
|
298
|
+
Requires a **synchronous** redis client (``redis-py``'s ``redis.Redis``,
|
|
299
|
+
or any duck-typed equivalent exposing
|
|
300
|
+
``.set(key, value, nx=True, ex=ttl)``) — signature verification runs
|
|
301
|
+
synchronously even under ``AsyncBroker`` (``on_receive_async`` delegates
|
|
302
|
+
to the sync ``on_receive``), so there is no async code path to plug an
|
|
303
|
+
async redis client into.
|
|
304
|
+
|
|
305
|
+
Usage::
|
|
306
|
+
|
|
307
|
+
import redis
|
|
308
|
+
from rabbitkit.middleware.signing import RedisNonceCache, SigningConfig
|
|
309
|
+
|
|
310
|
+
cache = RedisNonceCache(redis.Redis(host="redis", port=6379))
|
|
311
|
+
config = SigningConfig(secret_key="shared-secret", nonce_cache=cache)
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, redis_client: Any, key_prefix: str = "rabbitkit:nonce:") -> None:
|
|
315
|
+
self._redis = redis_client
|
|
316
|
+
self._prefix = key_prefix
|
|
317
|
+
|
|
318
|
+
def _key(self, nonce: str) -> str:
|
|
319
|
+
return f"{self._prefix}{nonce}"
|
|
320
|
+
|
|
321
|
+
def seen(self, nonce: str, ttl: float) -> bool:
|
|
322
|
+
"""Atomically record *nonce*. True = first-seen, False = replay.
|
|
323
|
+
|
|
324
|
+
``SET NX EX`` only sets the key (and returns truthy) when it does not
|
|
325
|
+
already exist, so this is the same first-seen/duplicate semantics as
|
|
326
|
+
:class:`TTLSetNonceCache`, but enforced by Redis across every process
|
|
327
|
+
sharing this client/prefix rather than by an in-process lock.
|
|
328
|
+
"""
|
|
329
|
+
return bool(self._redis.set(self._key(nonce), "1", nx=True, ex=max(1, int(ttl))))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True, slots=True)
|
|
333
|
+
class SigningConfig:
|
|
334
|
+
"""Configuration for message signing.
|
|
335
|
+
|
|
336
|
+
Attributes:
|
|
337
|
+
secret_key: Shared secret for HMAC computation.
|
|
338
|
+
algorithm: Hash algorithm ("hmac-sha256" or "hmac-sha512").
|
|
339
|
+
header_name: AMQP header name for the signature.
|
|
340
|
+
reject_unsigned: If True, reject messages without a signature.
|
|
341
|
+
reject_invalid: If True, reject messages with invalid signatures.
|
|
342
|
+
max_skew: Max allowed |now - timestamp| skew in seconds (both past/future).
|
|
343
|
+
Also the nonce's replay-window TTL (H4: tightened default of 60s —
|
|
344
|
+
shrink further for high-value/financial traffic; a captured
|
|
345
|
+
signature is replayable within this window on any process that
|
|
346
|
+
has not already seen the nonce).
|
|
347
|
+
require_freshness: If True (default), reject messages lacking freshness
|
|
348
|
+
headers. If False, accept legacy body-only signatures with a warning.
|
|
349
|
+
nonce_cache: Pluggable nonce seen-set. ``None`` means a default
|
|
350
|
+
in-memory ``TTLSetNonceCache`` is created lazily by the middleware
|
|
351
|
+
(H4: per-process only — use :class:`RedisNonceCache` or your own
|
|
352
|
+
shared implementation for any multi-process/multi-pod deployment;
|
|
353
|
+
a ``RuntimeWarning`` is emitted when this default is left unset
|
|
354
|
+
with ``require_freshness=True``).
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
secret_key: str | bytes
|
|
358
|
+
algorithm: str = "hmac-sha256"
|
|
359
|
+
header_name: str = "x-rabbitkit-signature"
|
|
360
|
+
reject_unsigned: bool = False
|
|
361
|
+
reject_invalid: bool = True
|
|
362
|
+
# Replay protection. H4: default tightened from 300s to 60s — shrink
|
|
363
|
+
# further for payments/high-value traffic.
|
|
364
|
+
max_skew: float = 60.0
|
|
365
|
+
require_freshness: bool = True
|
|
366
|
+
nonce_cache: NonceCache | None = None
|
|
367
|
+
|
|
368
|
+
def __post_init__(self) -> None:
|
|
369
|
+
if self.algorithm not in ("hmac-sha256", "hmac-sha512"):
|
|
370
|
+
raise ValueError(f"Unsupported algorithm: {self.algorithm}. Use 'hmac-sha256' or 'hmac-sha512'.")
|
|
371
|
+
if self.max_skew <= 0:
|
|
372
|
+
raise ValueError("max_skew must be positive")
|
|
373
|
+
# L-1: the signature header must not collide with the freshness headers,
|
|
374
|
+
# otherwise the timestamp/nonce would be overwritten by the signature.
|
|
375
|
+
if self.header_name in (SigningMiddleware._TIMESTAMP_HEADER, SigningMiddleware._NONCE_HEADER):
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"header_name {self.header_name!r} collides with a freshness header; "
|
|
378
|
+
"choose a different signature header name."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class SigningMiddleware(BaseMiddleware):
|
|
383
|
+
"""Signs outgoing messages and verifies incoming signatures.
|
|
384
|
+
|
|
385
|
+
On publish: computes HMAC of body and adds signature to headers.
|
|
386
|
+
On receive: verifies signature against body, rejects if invalid.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
# ── Timestamp / nonce header names (replay protection) ──────────────
|
|
390
|
+
_TIMESTAMP_HEADER = "x-rabbitkit-sign-timestamp"
|
|
391
|
+
_NONCE_HEADER = "x-rabbitkit-sign-nonce"
|
|
392
|
+
|
|
393
|
+
def __init__(self, config: SigningConfig) -> None:
|
|
394
|
+
self._config = config
|
|
395
|
+
self._key = config.secret_key.encode("utf-8") if isinstance(config.secret_key, str) else config.secret_key
|
|
396
|
+
self._hash_name = "sha256" if config.algorithm == "hmac-sha256" else "sha512"
|
|
397
|
+
# Default to the in-memory cache so replay protection is functional
|
|
398
|
+
# out of the box for a single process; callers may inject a shared
|
|
399
|
+
# (e.g. RedisNonceCache) cache — see the H4 warning below.
|
|
400
|
+
self._nonce_cache: NonceCache = config.nonce_cache if config.nonce_cache is not None else TTLSetNonceCache()
|
|
401
|
+
# H4: the default in-memory cache is per-process — a replay that
|
|
402
|
+
# lands on a different worker/pod, or after a restart, is invisible
|
|
403
|
+
# to it. This can't detect an actual multi-process deployment, so it
|
|
404
|
+
# fires whenever the default is left in place with require_freshness
|
|
405
|
+
# (the risky combination), rather than silently claiming replay
|
|
406
|
+
# protection "works out of the box" for the common multi-replica case.
|
|
407
|
+
if config.nonce_cache is None and config.require_freshness:
|
|
408
|
+
import warnings
|
|
409
|
+
|
|
410
|
+
warnings.warn(
|
|
411
|
+
"SigningMiddleware is using the default in-memory TTLSetNonceCache "
|
|
412
|
+
"with require_freshness=True. This nonce cache is per-process: in "
|
|
413
|
+
"any multi-process or multi-pod deployment (or after a restart), a "
|
|
414
|
+
"replayed message that lands on a different worker will NOT be "
|
|
415
|
+
"detected. Pass nonce_cache=RedisNonceCache(...) (or your own "
|
|
416
|
+
"NonceCache) to share the seen-set across processes before relying "
|
|
417
|
+
"on this for security-sensitive traffic.",
|
|
418
|
+
RuntimeWarning,
|
|
419
|
+
stacklevel=2,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _compute_signature(self, body: bytes) -> str:
|
|
423
|
+
"""Compute the legacy body-only HMAC signature (backward-compat).
|
|
424
|
+
|
|
425
|
+
Deliberately NOT extended to cover routing metadata (H3): this path
|
|
426
|
+
only runs when ``require_freshness=False`` and verifies signatures
|
|
427
|
+
from producers that predate the freshness headers — external/legacy
|
|
428
|
+
signers whose signing scheme this library cannot retroactively
|
|
429
|
+
change. It carries no replay protection either; see the module
|
|
430
|
+
docstring. Prefer the default ``require_freshness=True`` (the fresh
|
|
431
|
+
path below) for any security-sensitive deployment.
|
|
432
|
+
"""
|
|
433
|
+
return hmac.new(self._key, body, getattr(hashlib, self._hash_name)).hexdigest()
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _canonical_route(
|
|
437
|
+
exchange: str,
|
|
438
|
+
routing_key: str,
|
|
439
|
+
content_encoding: str | None,
|
|
440
|
+
reply_to: str | None,
|
|
441
|
+
) -> bytes:
|
|
442
|
+
"""Canonical, NUL-delimited encoding of the routing metadata bound
|
|
443
|
+
into the fresh signature (H3).
|
|
444
|
+
|
|
445
|
+
Covers exactly ``exchange``, ``routing_key``, ``content_encoding``,
|
|
446
|
+
and ``reply_to`` — the fields an attacker could otherwise flip on a
|
|
447
|
+
captured, validly-signed message to re-route it, redirect an RPC
|
|
448
|
+
reply, or hit a different decompression path, all without touching
|
|
449
|
+
(or being able to forge) the body. Headers other than the
|
|
450
|
+
signature/timestamp/nonce triplet itself are NOT covered — do not
|
|
451
|
+
rely on freeform headers for security-critical routing decisions
|
|
452
|
+
under this middleware. NUL (``\\x00``) is used as the field
|
|
453
|
+
delimiter (including a trailing one) so concatenation cannot make
|
|
454
|
+
two different field combinations hash identically.
|
|
455
|
+
"""
|
|
456
|
+
return f"{exchange or ''}\x00{routing_key or ''}\x00{content_encoding or ''}\x00{reply_to or ''}\x00".encode()
|
|
457
|
+
|
|
458
|
+
def _compute_fresh_signature(
|
|
459
|
+
self,
|
|
460
|
+
timestamp: float,
|
|
461
|
+
nonce: str,
|
|
462
|
+
body: bytes,
|
|
463
|
+
*,
|
|
464
|
+
exchange: str = "",
|
|
465
|
+
routing_key: str = "",
|
|
466
|
+
content_encoding: str | None = None,
|
|
467
|
+
reply_to: str | None = None,
|
|
468
|
+
) -> str:
|
|
469
|
+
"""Compute the replay-protected, route-bound signature (H3).
|
|
470
|
+
|
|
471
|
+
Signs ``timestamp:nonce:`` + the canonical routing metadata
|
|
472
|
+
(:meth:`_canonical_route`) + ``body``. Binding the routing metadata
|
|
473
|
+
means changing ``exchange``/``routing_key``/``content_encoding``/
|
|
474
|
+
``reply_to`` on a delivered message invalidates the signature even
|
|
475
|
+
when the body, timestamp, and nonce are all byte-for-byte unchanged.
|
|
476
|
+
"""
|
|
477
|
+
route = self._canonical_route(exchange, routing_key, content_encoding, reply_to)
|
|
478
|
+
signed = f"{timestamp}:{nonce}:".encode() + route + body
|
|
479
|
+
return hmac.new(self._key, signed, getattr(hashlib, self._hash_name)).hexdigest()
|
|
480
|
+
|
|
481
|
+
def _verify_signature(self, body: bytes, signature: str | bytes) -> bool:
|
|
482
|
+
"""Verify HMAC signature using constant-time comparison."""
|
|
483
|
+
expected = self._compute_signature(body)
|
|
484
|
+
return _const_time_eq(expected, signature)
|
|
485
|
+
|
|
486
|
+
def _verify_fresh_signature(
|
|
487
|
+
self,
|
|
488
|
+
timestamp: float,
|
|
489
|
+
nonce: str,
|
|
490
|
+
body: bytes,
|
|
491
|
+
signature: str | bytes,
|
|
492
|
+
*,
|
|
493
|
+
exchange: str = "",
|
|
494
|
+
routing_key: str = "",
|
|
495
|
+
content_encoding: str | None = None,
|
|
496
|
+
reply_to: str | None = None,
|
|
497
|
+
) -> bool:
|
|
498
|
+
"""Verify replay-protected, route-bound signature (H3) using
|
|
499
|
+
constant-time comparison."""
|
|
500
|
+
expected = self._compute_fresh_signature(
|
|
501
|
+
timestamp,
|
|
502
|
+
nonce,
|
|
503
|
+
body,
|
|
504
|
+
exchange=exchange,
|
|
505
|
+
routing_key=routing_key,
|
|
506
|
+
content_encoding=content_encoding,
|
|
507
|
+
reply_to=reply_to,
|
|
508
|
+
)
|
|
509
|
+
return _const_time_eq(expected, signature)
|
|
510
|
+
|
|
511
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
def _sign_envelope(self, envelope: MessageEnvelope) -> MessageEnvelope:
|
|
514
|
+
"""Return a new envelope with signature (and freshness) headers added."""
|
|
515
|
+
headers = dict(envelope.headers) if envelope.headers else {}
|
|
516
|
+
# H4: always a fresh random nonce, independent of message_id. A
|
|
517
|
+
# message_id is often caller-supplied and may be reused across
|
|
518
|
+
# publishes (e.g. an at-least-once retry re-sending the "same"
|
|
519
|
+
# message) — using it as the nonce would make the nonce predictable
|
|
520
|
+
# and/or reused, weakening the seen-set's replay guarantee, which
|
|
521
|
+
# depends on the nonce being unique per signing operation.
|
|
522
|
+
nonce = uuid.uuid4().hex
|
|
523
|
+
timestamp = time.time()
|
|
524
|
+
sig = self._compute_fresh_signature(
|
|
525
|
+
timestamp,
|
|
526
|
+
nonce,
|
|
527
|
+
envelope.body,
|
|
528
|
+
exchange=envelope.exchange,
|
|
529
|
+
routing_key=envelope.routing_key,
|
|
530
|
+
content_encoding=envelope.content_encoding,
|
|
531
|
+
reply_to=envelope.reply_to,
|
|
532
|
+
)
|
|
533
|
+
headers[self._config.header_name] = sig
|
|
534
|
+
headers[self._TIMESTAMP_HEADER] = str(timestamp)
|
|
535
|
+
headers[self._NONCE_HEADER] = nonce
|
|
536
|
+
return MessageEnvelope(
|
|
537
|
+
routing_key=envelope.routing_key,
|
|
538
|
+
body=envelope.body,
|
|
539
|
+
exchange=envelope.exchange,
|
|
540
|
+
correlation_id=envelope.correlation_id,
|
|
541
|
+
headers=headers,
|
|
542
|
+
message_id=envelope.message_id,
|
|
543
|
+
reply_to=envelope.reply_to,
|
|
544
|
+
timestamp=envelope.timestamp,
|
|
545
|
+
content_type=envelope.content_type,
|
|
546
|
+
content_encoding=envelope.content_encoding,
|
|
547
|
+
expiration=envelope.expiration,
|
|
548
|
+
priority=envelope.priority,
|
|
549
|
+
mandatory=envelope.mandatory,
|
|
550
|
+
delivery_mode=envelope.delivery_mode,
|
|
551
|
+
type=envelope.type,
|
|
552
|
+
user_id=envelope.user_id,
|
|
553
|
+
app_id=envelope.app_id,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# ── Publish: sign outgoing messages ──────────────────────────────────
|
|
557
|
+
|
|
558
|
+
def publish_scope(self, call_next: Any, envelope: MessageEnvelope) -> Any:
|
|
559
|
+
"""Add HMAC signature to outgoing message headers."""
|
|
560
|
+
signed = self._sign_envelope(envelope)
|
|
561
|
+
return call_next(signed)
|
|
562
|
+
|
|
563
|
+
async def publish_scope_async(self, call_next: Any, envelope: MessageEnvelope) -> Any:
|
|
564
|
+
"""Async variant — add HMAC signature."""
|
|
565
|
+
signed = self._sign_envelope(envelope)
|
|
566
|
+
return await call_next(signed)
|
|
567
|
+
|
|
568
|
+
# ── Receive: verify incoming signatures ──────────────────────────────
|
|
569
|
+
|
|
570
|
+
def on_receive(self, message: RabbitMessage) -> None:
|
|
571
|
+
"""Verify signature on incoming message.
|
|
572
|
+
|
|
573
|
+
Replay protection rules (see module docstring):
|
|
574
|
+
* Freshness headers present → skew + nonce always enforced.
|
|
575
|
+
* Headers absent + require_freshness=True → rejected (strict).
|
|
576
|
+
* Headers absent + require_freshness=False → legacy body-only verify + warn.
|
|
577
|
+
"""
|
|
578
|
+
sig = message.headers.get(self._config.header_name)
|
|
579
|
+
if sig is None:
|
|
580
|
+
if self._config.reject_unsigned:
|
|
581
|
+
raise InvalidSignatureError(f"Message has no {self._config.header_name} header")
|
|
582
|
+
return
|
|
583
|
+
# L-2: a non-str/bytes signature header would make hmac.compare_digest
|
|
584
|
+
# raise TypeError; surface it as a permanent InvalidSignatureError instead.
|
|
585
|
+
if not isinstance(sig, (str, bytes)):
|
|
586
|
+
raise InvalidSignatureError("signature header is not a string/bytes")
|
|
587
|
+
|
|
588
|
+
ts_raw = message.headers.get(self._TIMESTAMP_HEADER)
|
|
589
|
+
nonce = message.headers.get(self._NONCE_HEADER)
|
|
590
|
+
|
|
591
|
+
if ts_raw is not None and nonce is not None:
|
|
592
|
+
# Fresh producer path — enforce skew + nonce unconditionally.
|
|
593
|
+
try:
|
|
594
|
+
timestamp = float(ts_raw)
|
|
595
|
+
except (TypeError, ValueError) as exc:
|
|
596
|
+
raise InvalidSignatureError("Invalid signature timestamp header") from exc
|
|
597
|
+
# L-3: NaN/Inf would bypass the skew check (abs(now - NaN) is NaN,
|
|
598
|
+
# which compares False to any threshold); reject non-finite values.
|
|
599
|
+
if not math.isfinite(timestamp):
|
|
600
|
+
raise InvalidSignatureError("non-finite timestamp")
|
|
601
|
+
|
|
602
|
+
skew = abs(time.time() - timestamp)
|
|
603
|
+
if skew > self._config.max_skew:
|
|
604
|
+
raise InvalidSignatureError(
|
|
605
|
+
f"Signature timestamp outside max_skew ({skew:.1f}s > {self._config.max_skew}s)"
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if self._config.reject_invalid and not self._verify_fresh_signature(
|
|
609
|
+
timestamp,
|
|
610
|
+
str(nonce),
|
|
611
|
+
message.body,
|
|
612
|
+
sig,
|
|
613
|
+
exchange=message.exchange,
|
|
614
|
+
routing_key=message.routing_key,
|
|
615
|
+
content_encoding=message.content_encoding,
|
|
616
|
+
reply_to=message.reply_to,
|
|
617
|
+
):
|
|
618
|
+
raise InvalidSignatureError("Message signature verification failed")
|
|
619
|
+
|
|
620
|
+
# Nonce replay check — mark after signature verifies so bogus
|
|
621
|
+
# messages can't burn nonces. TTL covers the replay window.
|
|
622
|
+
if not self._nonce_cache.seen(str(nonce), self._config.max_skew):
|
|
623
|
+
# Duplicate nonce. H1: a BROKER REDELIVERY (redelivered=True) of
|
|
624
|
+
# an unacked message legitimately carries the same nonce — a
|
|
625
|
+
# transient handler failure → nack/requeue → redelivery must not
|
|
626
|
+
# be destroyed as a "replay". Only a FRESH delivery
|
|
627
|
+
# (redelivered=False) reusing a seen nonce is a real replay: an
|
|
628
|
+
# attacker re-publishing a captured message arrives as a new
|
|
629
|
+
# delivery, not a broker redelivery (the redelivered flag is set
|
|
630
|
+
# by the broker, not the publisher, so it can't be forged).
|
|
631
|
+
if not message.redelivered:
|
|
632
|
+
raise InvalidSignatureError("Replay detected: duplicate nonce")
|
|
633
|
+
logger.debug(
|
|
634
|
+
"Duplicate nonce on a broker redelivery (redelivered=True) — "
|
|
635
|
+
"allowed as a legitimate redelivery, not a replay."
|
|
636
|
+
)
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
# No freshness headers — legacy producer.
|
|
640
|
+
if self._config.require_freshness:
|
|
641
|
+
if self._config.reject_invalid:
|
|
642
|
+
raise InvalidSignatureError("Missing freshness headers (require_freshness=True)")
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
logger.warning(
|
|
646
|
+
"Message from %s without %s/%s headers — verifying body-only signature (no replay protection).",
|
|
647
|
+
self._config.header_name,
|
|
648
|
+
self._TIMESTAMP_HEADER,
|
|
649
|
+
self._NONCE_HEADER,
|
|
650
|
+
)
|
|
651
|
+
if self._config.reject_invalid and not self._verify_signature(message.body, sig):
|
|
652
|
+
raise InvalidSignatureError("Message signature verification failed")
|
|
653
|
+
|
|
654
|
+
async def on_receive_async(self, message: RabbitMessage) -> None:
|
|
655
|
+
"""Async variant — verify signature."""
|
|
656
|
+
self.on_receive(message)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def check_signing_retry_conflict(route_name: str, route_middlewares: list[Any]) -> None:
|
|
660
|
+
"""H1: signing and retry are mutually exclusive on the same route.
|
|
661
|
+
|
|
662
|
+
A signed message's signature covers its exchange + routing_key, but
|
|
663
|
+
``RetryMiddleware`` re-enters a retried message via the default exchange
|
|
664
|
+
with ``routing_key=<queue-name>`` — so verification fails on every
|
|
665
|
+
post-delay redelivery and the message dead-letters instead of retrying.
|
|
666
|
+
(The nonce would also be freshly re-checked.) There is no correct
|
|
667
|
+
behavior for the combination, so fail fast at startup with a clear
|
|
668
|
+
message rather than silently destroying every retried signed message.
|
|
669
|
+
|
|
670
|
+
Called by the brokers for any route that has retry enabled. ``on_receive``
|
|
671
|
+
verification failures on signed routes should be given a dead-letter path
|
|
672
|
+
(the default ``reject_without_dlx='auto_provision'`` provides one).
|
|
673
|
+
"""
|
|
674
|
+
if any(isinstance(mw, SigningMiddleware) for mw in route_middlewares):
|
|
675
|
+
raise ConfigurationError(
|
|
676
|
+
f"Route '{route_name}': SigningMiddleware is incompatible with retry. "
|
|
677
|
+
"Retry re-publishes a failed message via the default exchange with a "
|
|
678
|
+
"different routing key, which the signature covers — so a retried signed "
|
|
679
|
+
"message never re-verifies and dead-letters instead of retrying. Use one "
|
|
680
|
+
"or the other on this route: disable retry (retry=RETRY_DISABLED), or drop "
|
|
681
|
+
"SigningMiddleware and verify signatures at the trust boundary instead."
|
|
682
|
+
)
|