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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. 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
+ )