messagefoundry 0.1.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 (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,281 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """REST transport: an HTTP destination that delivers each transformed payload to a URL.
4
+
5
+ The **destination** sends one payload (the request body, already produced by the Handler) to a
6
+ configured HTTP(S) endpoint and maps the outcome onto the engine's retry model:
7
+
8
+ - **2xx** → delivered.
9
+ - **5xx, 408, 429, connection/DNS/TLS/timeout** → :class:`DeliveryError` (transient — the pipeline
10
+ retries with backoff).
11
+ - **other 4xx, or a refused 3xx redirect** → :class:`NegativeAckError` (``permanent=True``) so it
12
+ **dead-letters immediately** instead of blocking the FIFO lane forever on a request the endpoint
13
+ will never accept.
14
+
15
+ Standard library only (``urllib.request``) — no new dependency (ADR 0003). Redirects are **refused**
16
+ (a 3xx could divert PHI to an unintended host) and the scheme is constrained to http/https, mirroring
17
+ the alert-webhook hardening (ASVS 15.3.2 / 1.3.6); the `[egress].allowed_http` allowlist is the
18
+ fail-closed host gate (enforced by the runner at load/reload/start). Per-connection
19
+ ``verify_tls=False`` is honored only when the dev escape ``MEFOR_ALLOW_INSECURE_TLS`` is set, exactly
20
+ like LDAPS / the SQL Server backend.
21
+
22
+ There is **no REST source yet** — a non-HL7 source needs the payload-agnostic ingress decided in
23
+ ADR 0003 (its own follow-up ADR). This is the first non-HL7 connector.
24
+
25
+ **Idempotency.** Delivery is at-least-once, so a retry re-sends the request; the receiving endpoint
26
+ **must be idempotent** (an idempotency key, or a natural upsert). See docs/CONNECTIONS.md.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import base64
33
+ import logging
34
+ import ssl
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ from typing import Any
39
+
40
+ from messagefoundry.config.models import ConnectorType, Destination
41
+ from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
42
+ from messagefoundry.transports.base import (
43
+ DeliveryError,
44
+ DeliveryResponse,
45
+ DestinationConnector,
46
+ NegativeAckError,
47
+ register_destination,
48
+ )
49
+ from messagefoundry.transports.signing import MessageSigner, signer_from_destination
50
+
51
+ __all__ = [
52
+ "RestDestination",
53
+ "enforce_outbound_length_limits",
54
+ "refuse_cleartext_credentials",
55
+ ]
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # WP-L3-09 (ASVS 4.2.5): bound the resolved outbound URL and each built header value at connector
60
+ # construction. Every value is operator-supplied today (config + env()), so this is defense-in-depth
61
+ # that also surfaces a misconfiguration early — e.g. an env() secret that resolved to an unexpected
62
+ # blob, or a runaway concatenated header — as a clear config error instead of a wire-level surprise on
63
+ # the first delivery. 8 KiB comfortably exceeds any legitimate endpoint URL or Basic/Bearer credential.
64
+ MAX_OUTBOUND_URL_LEN = 8192
65
+ MAX_OUTBOUND_HEADER_VALUE_LEN = 8192
66
+
67
+ # 4xx statuses worth retrying anyway: the server is up but momentarily unwilling, not a hard reject.
68
+ _RETRYABLE_4XX = frozenset({408, 429})
69
+
70
+
71
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
72
+ """Refuse to follow HTTP redirects (ASVS 15.3.2): a 3xx could divert a PHI-bearing POST to an
73
+ unintended host. Returning ``None`` makes urllib raise the 3xx as an ``HTTPError`` instead of
74
+ following it, so the delivery is classified (permanent) rather than silently redirected."""
75
+
76
+ def redirect_request(
77
+ self,
78
+ req: urllib.request.Request,
79
+ fp: Any,
80
+ code: int,
81
+ msg: str,
82
+ headers: Any,
83
+ newurl: str,
84
+ ) -> urllib.request.Request | None:
85
+ return None
86
+
87
+
88
+ # Shared opener that verifies TLS (urllib's default context) and never follows redirects.
89
+ _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler)
90
+
91
+
92
+ def _insecure_opener() -> urllib.request.OpenerDirector:
93
+ """A no-redirect opener that does **not** verify TLS — built only when the dev escape is set."""
94
+ ctx = ssl.create_default_context()
95
+ ctx.check_hostname = False
96
+ ctx.verify_mode = ssl.CERT_NONE
97
+ return urllib.request.build_opener(_NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
98
+
99
+
100
+ def _redact_url(url: str) -> str:
101
+ """``scheme://host[:port]/path`` only — drops query/userinfo so a token or PHI in the query
102
+ string never reaches a log line."""
103
+ p = urllib.parse.urlsplit(url)
104
+ port = f":{p.port}" if p.port else ""
105
+ return f"{p.scheme}://{p.hostname or ''}{port}{p.path}"
106
+
107
+
108
+ def refuse_cleartext_credentials(scheme: str, headers: dict[str, str], url: str) -> None:
109
+ """Refuse to send credentials over a cleartext (``http``) channel.
110
+
111
+ Basic/bearer auth in an ``Authorization`` header over plain ``http`` puts the credential on the
112
+ wire (and the body is PHI). Mirrors the ``verify_tls=false`` posture: refused unless the explicit
113
+ dev/trusted-network escape ``MEFOR_ALLOW_INSECURE_TLS`` is set, and logged loudly when allowed.
114
+ Shared by the REST and SOAP destinations (SOAP reuses REST's HTTP plumbing)."""
115
+ if scheme != "http" or "Authorization" not in headers:
116
+ return
117
+ if not insecure_tls_allowed():
118
+ raise ValueError(
119
+ "destination sends credentials (Authorization header) over cleartext http; refused "
120
+ f"unless {INSECURE_TLS_ESCAPE_ENV} is set — use https"
121
+ )
122
+ logger.warning(
123
+ "destination %s sends credentials over CLEARTEXT http (no TLS)", _redact_url(url)
124
+ )
125
+
126
+
127
+ def enforce_outbound_length_limits(url: str, headers: dict[str, str]) -> None:
128
+ """Reject an over-length outbound URL or request-header value at connector construction (ASVS
129
+ 4.2.5). Shared by the REST and SOAP destinations (SOAP reuses REST's HTTP plumbing). Raises
130
+ :class:`ValueError` with a PHI-free message naming only the limit and the offending header name —
131
+ never the value (a header may carry a credential)."""
132
+ if len(url) > MAX_OUTBOUND_URL_LEN:
133
+ raise ValueError(
134
+ f"outbound URL is {len(url)} chars, over the {MAX_OUTBOUND_URL_LEN}-char limit; "
135
+ "check the configured 'url' / its env() value"
136
+ )
137
+ for name, value in headers.items():
138
+ if len(value) > MAX_OUTBOUND_HEADER_VALUE_LEN:
139
+ raise ValueError(
140
+ f"outbound header {name!r} is {len(value)} chars, over the "
141
+ f"{MAX_OUTBOUND_HEADER_VALUE_LEN}-char limit; check the configured header / "
142
+ "credential value"
143
+ )
144
+
145
+
146
+ class RestDestination(DestinationConnector):
147
+ """Deliver each transformed payload to an HTTP(S) endpoint (outbound only today)."""
148
+
149
+ def __init__(self, config: Destination) -> None:
150
+ s = config.settings
151
+ url = s.get("url")
152
+ if not isinstance(url, str) or not url:
153
+ raise ValueError("REST destination requires a 'url' setting")
154
+ scheme = urllib.parse.urlsplit(url).scheme.lower()
155
+ if scheme not in ("http", "https"):
156
+ raise ValueError(f"REST destination 'url' must be http or https, got scheme {scheme!r}")
157
+ self.url = url
158
+ self.method: str = str(s.get("method", "POST")).upper()
159
+ self.timeout: float = float(s.get("timeout_seconds", 30.0))
160
+ self.encoding: str = s.get("encoding", "utf-8")
161
+ # ADR 0013: capture the HTTP response body. Default False → returns None, byte-identical. A 2xx
162
+ # with a body → outcome='accepted'; a 2xx with an empty body → outcome='no_reply' (a successful
163
+ # round-trip, not an error). Non-2xx keeps today's DeliveryError/NegativeAckError classification.
164
+ self.capture_response: bool = bool(s.get("capture_response", False))
165
+ self._headers = self._build_headers(s)
166
+ enforce_outbound_length_limits(self.url, self._headers)
167
+ refuse_cleartext_credentials(scheme, self._headers, self.url)
168
+ # ASVS 4.1.5 (ADR 0018): opt-in detached-JWS signing of the outbound body. None = off (byte-
169
+ # identical). Built here so a bad key/algorithm fails loud at connector construction (check/
170
+ # dry-run/start), like a bad TLS cert; the per-request signature is minted in _post (off-loop).
171
+ self._signer: MessageSigner | None = signer_from_destination(config)
172
+ if bool(s.get("verify_tls", True)):
173
+ self._opener = _NO_REDIRECT_OPENER
174
+ else:
175
+ # Mirror the LDAPS / SQL Server posture: disabling verification is refused unless the
176
+ # operator set the explicit dev escape, and is logged loudly when used.
177
+ if scheme == "https" and not insecure_tls_allowed():
178
+ raise ValueError(
179
+ "REST destination verify_tls=false disables TLS certificate verification; "
180
+ f"refused unless {INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only)"
181
+ )
182
+ logger.warning(
183
+ "REST destination %s has TLS verification DISABLED (verify_tls=false)",
184
+ _redact_url(self.url),
185
+ )
186
+ self._opener = _insecure_opener()
187
+
188
+ @staticmethod
189
+ def _build_headers(s: dict[str, Any]) -> dict[str, str]:
190
+ """Content-Type + any static ``headers`` + optional bearer/basic auth. Secrets (token,
191
+ password) come in as resolved top-level settings (``env()``-friendly); static ``headers`` are
192
+ literal and must not carry secrets (they aren't ``env()``-resolved)."""
193
+ headers: dict[str, str] = {"Content-Type": str(s.get("content_type", "application/json"))}
194
+ extra = s.get("headers") or {}
195
+ if isinstance(extra, dict):
196
+ headers.update({str(k): str(v) for k, v in extra.items()})
197
+ token = s.get("bearer_token")
198
+ if token:
199
+ headers["Authorization"] = f"Bearer {token}"
200
+ user, password = s.get("basic_user"), s.get("basic_password")
201
+ if user and password:
202
+ raw = f"{user}:{password}".encode()
203
+ headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
204
+ return headers
205
+
206
+ async def send(self, payload: str) -> DeliveryResponse | None:
207
+ # urllib is blocking — keep it off the event loop (the delivery worker awaits this).
208
+ body, status = await asyncio.to_thread(self._post, payload)
209
+ if not self.capture_response:
210
+ return None
211
+ if body == "":
212
+ # A successful round-trip with no payload — captured as a deliberate empty reply, NOT an
213
+ # error (the request succeeded). Distinct from a read failure, which raised above.
214
+ return DeliveryResponse(body="", outcome="no_reply", detail=f"HTTP {status}")
215
+ return DeliveryResponse(body=body, outcome="accepted", detail=f"HTTP {status}")
216
+
217
+ async def test_connection(self) -> None:
218
+ await asyncio.to_thread(self._probe)
219
+
220
+ def _probe(self) -> None:
221
+ # Reachability only: a HEAD reaches the endpoint without POSTing a body. An HTTP response means
222
+ # the host answered, so a 405 (HEAD not allowed on a POST endpoint) is still a pass — but a 401/
223
+ # 403 means the configured credentials would be rejected, which a real delivery dead-letters, so
224
+ # surface it as a failure. Connection/DNS/TLS/timeout is always a fail.
225
+ req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
226
+ self.url, headers=self._headers, method="HEAD"
227
+ )
228
+ try:
229
+ with self._opener.open(req, timeout=self.timeout) as resp:
230
+ resp.read()
231
+ except urllib.error.HTTPError as exc:
232
+ if exc.code in (401, 403):
233
+ raise DeliveryError(
234
+ f"REST {_redact_url(self.url)} returned HTTP {exc.code} (check credentials)"
235
+ ) from exc
236
+ return # any other status (the host answered) → reachable
237
+ except urllib.error.URLError as exc: # DNS / connection refused / TLS / timeout
238
+ raise DeliveryError(f"REST {_redact_url(self.url)} unreachable: {exc.reason}") from exc
239
+ except (TimeoutError, OSError) as exc:
240
+ raise DeliveryError(f"REST {_redact_url(self.url)} failed: {exc}") from exc
241
+
242
+ def _post(self, payload: str) -> tuple[str, int]:
243
+ data = payload.encode(self.encoding)
244
+ headers = self._headers
245
+ if self._signer is not None:
246
+ # ASVS 4.1.5 (ADR 0018): mint a detached JWS over the exact body bytes and carry it in a
247
+ # per-request header. Minted here in send()'s off-loop worker, past the queue boundary, so
248
+ # a retry re-mints it (re-run purity holds, like the WS-Security nonce — ADR 0015).
249
+ headers = {**self._headers, **self._signer.signature_headers(data)}
250
+ req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
251
+ self.url,
252
+ data=data,
253
+ headers=headers,
254
+ method=self.method,
255
+ )
256
+ try:
257
+ with self._opener.open(req, timeout=self.timeout) as resp:
258
+ # Read the body (drains the connection for clean close; returned for capture). 2xx ⇒
259
+ # delivered. Decoding a drained body is cheap, so this stays byte-identical when capture
260
+ # is off (the worker just ignores the return).
261
+ body = resp.read().decode(self.encoding, errors="replace")
262
+ status = int(getattr(resp, "status", 200))
263
+ return body, status
264
+ except urllib.error.HTTPError as exc:
265
+ status = exc.code
266
+ if status in _RETRYABLE_4XX or 500 <= status < 600:
267
+ raise DeliveryError(f"REST {_redact_url(self.url)} returned HTTP {status}") from exc
268
+ # Other 4xx (and a refused 3xx) — the endpoint won't accept this request as-is; fail fast
269
+ # to the dead-letter queue rather than retry a permanent rejection forever.
270
+ raise NegativeAckError(
271
+ f"REST {_redact_url(self.url)} rejected with HTTP {status}",
272
+ code=str(status),
273
+ permanent=True,
274
+ ) from exc
275
+ except urllib.error.URLError as exc: # DNS / connection refused / TLS / timeout
276
+ raise DeliveryError(f"REST {_redact_url(self.url)} unreachable: {exc.reason}") from exc
277
+ except (TimeoutError, OSError) as exc:
278
+ raise DeliveryError(f"REST {_redact_url(self.url)} failed: {exc}") from exc
279
+
280
+
281
+ register_destination(ConnectorType.REST, RestDestination)
@@ -0,0 +1,321 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Opt-in per-connection **detached-JWS** message signing for REST/SOAP outbound (ASVS 4.1.5, ADR 0018).
4
+
5
+ A signed outbound puts a message-level digital signature **on top of** the transport (TLS) so a
6
+ partner — or any downstream hop — can verify the payload's **integrity and origin** independent of the
7
+ channel. It is OFF unless a connection configures it (:class:`~messagefoundry.config.models.OutboundSigning`).
8
+
9
+ **What is minted.** A **detached JWS** (RFC 7515 Appendix F): the compact serialization
10
+ ``BASE64URL(protected) || '.' || '' || '.' || BASE64URL(signature)`` (``header..signature`` — the
11
+ payload segment is empty), where the signature is computed over
12
+ ``ASCII(BASE64URL(protected) || '.' || BASE64URL(payload))``. The payload itself stays the HTTP body
13
+ (not duplicated in the header); the receiver reconstructs the signing input from the **exact body
14
+ bytes it received** and the detached header, then verifies against the agreed **public** key.
15
+
16
+ **Where it is minted.** In the connector's ``send()`` boundary — over the exact bytes that go on the
17
+ wire (for SOAP WS-\\* that is the wrapped envelope, built in ``send()``) — and **inside the off-loop
18
+ worker thread** ``send()`` already uses. That is *past the queue boundary*, exactly like the
19
+ WS-Security timestamp/nonce (ADR 0015 §1): a re-run/retry re-mints the signature, so routers and
20
+ transforms stay pure and the at-least-once invariant holds even for the randomized algorithms (PS256/
21
+ ES256 produce a fresh signature per call — fine here, never in a transform).
22
+
23
+ **Crypto.** Core ``cryptography`` only — **no new dependency** (ADR 0018). RSA (``RS256`` PKCS1-v1_5,
24
+ ``PS256`` PSS) or ECDSA (``ES256`` P-256), all SHA-256. ECDSA's DER signature is converted to the JOSE
25
+ fixed-width ``r||s`` form (and back on verify), as JWS requires.
26
+
27
+ **Key management.** The private key is operator-supplied as inline PEM (via ``env()``) or a PEM file
28
+ path (OS-protected, like a TLS key); it never leaves the box — only the public-verifiable signature
29
+ does. A managed key provider (HSM/KMS/Vault) is the separate ADR 0019 follow-up.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import json
36
+ from typing import TYPE_CHECKING
37
+
38
+ from cryptography.hazmat.primitives import hashes, serialization
39
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
40
+ from cryptography.hazmat.primitives.asymmetric.utils import (
41
+ decode_dss_signature,
42
+ encode_dss_signature,
43
+ )
44
+
45
+ from messagefoundry.config.models import (
46
+ ConnectorType,
47
+ Destination,
48
+ OutboundSigning,
49
+ SignatureAlgorithm,
50
+ )
51
+
52
+ if (
53
+ TYPE_CHECKING
54
+ ): # only for the with_signing() annotation — avoid importing the heavy wiring module
55
+ from collections.abc import Iterable
56
+
57
+ from messagefoundry.config.wiring import ConnectionSpec
58
+
59
+ __all__ = [
60
+ "MessageSigner",
61
+ "SigningError",
62
+ "signer_from_destination",
63
+ "verify_detached_jws",
64
+ "with_signing",
65
+ ]
66
+
67
+ # A signing key is RSA or EC; these are the two key/public-key type pairs we accept.
68
+ _PrivateKey = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey
69
+ _PublicKey = rsa.RSAPublicKey | ec.EllipticCurvePublicKey
70
+
71
+ # ES256 = ECDSA on P-256: each of r and s is a fixed 32-byte big-endian integer in the JOSE encoding.
72
+ _P256_COORD_BYTES = 32
73
+
74
+
75
+ class SigningError(ValueError):
76
+ """A signing key/algorithm/JWS was misconfigured or malformed.
77
+
78
+ Raised loud at connector construction (a bad key fails at ``check``/dry-run/start, like a bad TLS
79
+ cert) or from :func:`verify_detached_jws` on a structurally invalid JWS. A *signature mismatch* on
80
+ verify is the library's :class:`cryptography.exceptions.InvalidSignature`, not this."""
81
+
82
+
83
+ def _b64u_encode(raw: bytes) -> str:
84
+ """Base64url without padding (RFC 7515 §2)."""
85
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
86
+
87
+
88
+ def _b64u_decode(text: str) -> bytes:
89
+ """Inverse of :func:`_b64u_encode` (re-pads before decoding)."""
90
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
91
+
92
+
93
+ def _read_key_material(private_key: str) -> bytes:
94
+ """The PEM bytes of the signing key: the value verbatim if it is inline PEM, else read from the
95
+ path it names (a PEM key file, OS-protected like a TLS key)."""
96
+ if "-----BEGIN" in private_key:
97
+ return private_key.encode("utf-8")
98
+ try:
99
+ with open(private_key, "rb") as handle:
100
+ return handle.read()
101
+ except OSError as exc:
102
+ # Name the failure but never echo the path's contents; the path itself is operator config.
103
+ raise SigningError(
104
+ f"could not read the signing-key file {private_key!r}: {exc.strerror}"
105
+ ) from exc
106
+
107
+
108
+ def _load_private_key(private_key: str, password: str | None) -> _PrivateKey:
109
+ """Load + validate the PEM private key (RSA or EC). Errors are PHI/secret-free — they never
110
+ interpolate the key bytes or the (cryptography) deserialization detail, which could echo material."""
111
+ material = _read_key_material(private_key)
112
+ pw = password.encode("utf-8") if password else None
113
+ try:
114
+ key = serialization.load_pem_private_key(material, password=pw)
115
+ except (ValueError, TypeError):
116
+ raise SigningError(
117
+ "could not load the signing private key — check the PEM, and the password for an "
118
+ "encrypted key (set private_key_password via env())"
119
+ ) from None
120
+ if not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
121
+ raise SigningError(
122
+ f"signing key must be an RSA or EC (ECDSA) private key, got {type(key).__name__}"
123
+ )
124
+ return key
125
+
126
+
127
+ def _require_key_for_alg(key: _PrivateKey, alg: SignatureAlgorithm) -> None:
128
+ """Reject a key that can't produce ``alg`` — loud at construction, not a wire-time surprise."""
129
+ if alg in (SignatureAlgorithm.RS256, SignatureAlgorithm.PS256):
130
+ if not isinstance(key, rsa.RSAPrivateKey):
131
+ raise SigningError(f"{alg.value} requires an RSA private key, got {type(key).__name__}")
132
+ else: # ES256
133
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
134
+ raise SigningError(f"ES256 requires an EC private key, got {type(key).__name__}")
135
+ if key.curve.name != "secp256r1":
136
+ raise SigningError(
137
+ f"ES256 requires a P-256 (secp256r1) key, got curve {key.curve.name!r}"
138
+ )
139
+
140
+
141
+ def _sign(key: _PrivateKey, alg: SignatureAlgorithm, data: bytes) -> bytes:
142
+ """The raw JOSE signature bytes for ``data`` under ``alg`` (ECDSA DER → fixed-width ``r||s``)."""
143
+ if alg is SignatureAlgorithm.ES256:
144
+ if not isinstance(
145
+ key, ec.EllipticCurvePrivateKey
146
+ ): # defensive — guaranteed by construction
147
+ raise SigningError("ES256 requires an EC key")
148
+ der = key.sign(data, ec.ECDSA(hashes.SHA256()))
149
+ r, s = decode_dss_signature(der)
150
+ return r.to_bytes(_P256_COORD_BYTES, "big") + s.to_bytes(_P256_COORD_BYTES, "big")
151
+ if not isinstance(key, rsa.RSAPrivateKey): # defensive — guaranteed by construction
152
+ raise SigningError("RS256/PS256 require an RSA key")
153
+ if alg is SignatureAlgorithm.PS256:
154
+ return key.sign(
155
+ data,
156
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH),
157
+ hashes.SHA256(),
158
+ )
159
+ return key.sign(data, padding.PKCS1v15(), hashes.SHA256())
160
+
161
+
162
+ def _verify(public_key: _PublicKey, alg: SignatureAlgorithm, data: bytes, signature: bytes) -> None:
163
+ """Verify ``signature`` over ``data`` under ``alg``; raises
164
+ :class:`cryptography.exceptions.InvalidSignature` on mismatch, :class:`SigningError` on a
165
+ structurally wrong signature/key."""
166
+ if alg is SignatureAlgorithm.ES256:
167
+ if not isinstance(public_key, ec.EllipticCurvePublicKey):
168
+ raise SigningError(f"ES256 needs an EC public key, got {type(public_key).__name__}")
169
+ if len(signature) != 2 * _P256_COORD_BYTES:
170
+ raise SigningError("ES256 signature must be 64 bytes (r||s)")
171
+ r = int.from_bytes(signature[:_P256_COORD_BYTES], "big")
172
+ s = int.from_bytes(signature[_P256_COORD_BYTES:], "big")
173
+ public_key.verify(encode_dss_signature(r, s), data, ec.ECDSA(hashes.SHA256()))
174
+ return
175
+ if not isinstance(public_key, rsa.RSAPublicKey):
176
+ raise SigningError(f"{alg.value} needs an RSA public key, got {type(public_key).__name__}")
177
+ pad: padding.AsymmetricPadding = (
178
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH)
179
+ if alg is SignatureAlgorithm.PS256
180
+ else padding.PKCS1v15()
181
+ )
182
+ public_key.verify(signature, data, pad, hashes.SHA256())
183
+
184
+
185
+ class MessageSigner:
186
+ """Mints (and can verify) a detached JWS over a payload for one connection's signing config.
187
+
188
+ Built once at connector construction — the key is loaded and validated here, so a bad key/algorithm
189
+ fails loud at ``check``/dry-run/start. ``signature_headers`` is then called per delivery in the
190
+ connector's off-loop ``send()`` worker."""
191
+
192
+ def __init__(self, config: OutboundSigning) -> None:
193
+ self.algorithm: SignatureAlgorithm = config.algorithm
194
+ self.key_id: str | None = config.key_id
195
+ self.header_name: str = config.header_name
196
+ self._key: _PrivateKey = _load_private_key(config.private_key, config.private_key_password)
197
+ _require_key_for_alg(self._key, self.algorithm)
198
+ # The protected header is static per connection (only the signature varies per payload); a
199
+ # compact, sorted JSON encoding makes RS256 byte-stable across runs.
200
+ header: dict[str, str] = {"alg": self.algorithm.value}
201
+ if self.key_id:
202
+ header["kid"] = self.key_id
203
+ self._protected_b64 = _b64u_encode(
204
+ json.dumps(header, separators=(",", ":"), sort_keys=True).encode("utf-8")
205
+ )
206
+
207
+ @property
208
+ def public_key(self) -> _PublicKey:
209
+ """The verifying (public) key — for tests, round-trips, and exporting to a partner."""
210
+ return self._key.public_key()
211
+
212
+ def detached_jws(self, payload: bytes) -> str:
213
+ """The detached-JWS compact serialization (``header..signature``) over ``payload``."""
214
+ signing_input = f"{self._protected_b64}.{_b64u_encode(payload)}".encode("ascii")
215
+ signature = _sign(self._key, self.algorithm, signing_input)
216
+ return f"{self._protected_b64}..{_b64u_encode(signature)}"
217
+
218
+ def signature_headers(self, payload: bytes) -> dict[str, str]:
219
+ """The HTTP header(s) to add for ``payload``: ``{header_name: <detached JWS>}``."""
220
+ return {self.header_name: self.detached_jws(payload)}
221
+
222
+ def verify(self, jws: str, payload: bytes) -> None:
223
+ """Verify a JWS this signer produced against ``payload`` (the self-verify / round-trip path)."""
224
+ verify_detached_jws(jws, payload, self.public_key, allowed_algorithms=(self.algorithm,))
225
+
226
+
227
+ def verify_detached_jws(
228
+ jws: str,
229
+ payload: bytes,
230
+ public_key: _PublicKey,
231
+ *,
232
+ allowed_algorithms: Iterable[SignatureAlgorithm | str] | None = None,
233
+ ) -> None:
234
+ """Verify a detached JWS (``header..signature``) over ``payload`` with ``public_key``.
235
+
236
+ This is the **verify counterpart** of the signer — what a receiver (or a test) runs. ``payload`` is
237
+ the exact body bytes the message arrived as. Returns ``None`` on success; raises
238
+ :class:`cryptography.exceptions.InvalidSignature` if the signature does not match, or
239
+ :class:`SigningError` if the JWS is malformed / its ``alg`` is unsupported or not in
240
+ ``allowed_algorithms`` (pass the algorithms you expect to pin against an ``alg`` downgrade)."""
241
+ parts = jws.split(".")
242
+ if len(parts) != 3:
243
+ raise SigningError(
244
+ "detached JWS must be 'header..signature' (three '.'-separated segments)"
245
+ )
246
+ protected_b64, detached, signature_b64 = parts
247
+ if detached != "":
248
+ raise SigningError("detached JWS payload segment must be empty (RFC 7515 detached content)")
249
+ try:
250
+ header = json.loads(_b64u_decode(protected_b64))
251
+ except (ValueError, UnicodeDecodeError) as exc:
252
+ raise SigningError("detached JWS protected header is not valid base64url JSON") from exc
253
+ try:
254
+ alg = SignatureAlgorithm(header.get("alg"))
255
+ except ValueError as exc:
256
+ raise SigningError(f"unsupported or missing JWS alg {header.get('alg')!r}") from exc
257
+ if allowed_algorithms is not None:
258
+ allowed = {SignatureAlgorithm(a) for a in allowed_algorithms}
259
+ if alg not in allowed:
260
+ raise SigningError(
261
+ f"JWS alg {alg.value} is not in the allowed set {sorted(a.value for a in allowed)}"
262
+ )
263
+ signing_input = f"{protected_b64}.{_b64u_encode(payload)}".encode("ascii")
264
+ _verify(public_key, alg, signing_input, _b64u_decode(signature_b64))
265
+
266
+
267
+ def signer_from_destination(config: Destination) -> MessageSigner | None:
268
+ """The :class:`MessageSigner` for an outbound, or ``None`` when signing is off.
269
+
270
+ Prefers the typed :attr:`Destination.sign` the runner assembled; falls back to flat ``sign_*``
271
+ settings so a directly-built ``Destination`` (e.g. in a test) signs too. ``None`` when unconfigured
272
+ or ``enabled=False`` — every existing outbound is unchanged."""
273
+ signing = config.sign or OutboundSigning.from_settings(config.settings)
274
+ if signing is None or not signing.enabled:
275
+ return None
276
+ return MessageSigner(signing)
277
+
278
+
279
+ def with_signing(
280
+ spec: ConnectionSpec,
281
+ *,
282
+ private_key: object,
283
+ algorithm: SignatureAlgorithm | str = SignatureAlgorithm.RS256,
284
+ key_id: str | None = None,
285
+ header_name: str = "X-JWS-Signature",
286
+ private_key_password: object | None = None,
287
+ enabled: bool = True,
288
+ ) -> ConnectionSpec:
289
+ """Enable opt-in detached-JWS signing on a **REST/SOAP** outbound spec (ASVS 4.1.5, ADR 0018).
290
+
291
+ Compose it over the ``Rest()`` / ``Soap()`` factory — which supplies every transport default — so
292
+ signing is one code-first call and nothing about the connector changes::
293
+
294
+ from messagefoundry import Rest, env, outbound
295
+ from messagefoundry.transports.signing import with_signing
296
+
297
+ outbound("OB_ACME_ADT", with_signing(
298
+ Rest(url=env("acme_url")),
299
+ private_key=env("acme_sign_key"), # inline PEM via env(), or a PEM file path
300
+ algorithm="ES256",
301
+ key_id="acme-2026",
302
+ ))
303
+
304
+ ``private_key`` and ``private_key_password`` may be :func:`~messagefoundry.config.wiring.env`
305
+ references (resolved per environment) — keep every secret in ``env()``. Mutates ``spec``'s settings
306
+ in place and returns it. Signing is OFF on any spec this was not called on."""
307
+ if spec.type not in (ConnectorType.REST, ConnectorType.SOAP):
308
+ raise SigningError(
309
+ f"message signing applies to REST/SOAP outbound only, not {spec.type.value!r} (ADR 0018)"
310
+ )
311
+ spec.settings.update(
312
+ {
313
+ "sign_enabled": enabled,
314
+ "sign_algorithm": SignatureAlgorithm(algorithm).value,
315
+ "sign_private_key": private_key,
316
+ "sign_private_key_password": private_key_password,
317
+ "sign_key_id": key_id,
318
+ "sign_header": header_name,
319
+ }
320
+ )
321
+ return spec