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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- 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
|