mudraid-platform-middleware 1.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.
- mudraid_platform_middleware/__init__.py +55 -0
- mudraid_platform_middleware/_bundle.py +493 -0
- mudraid_platform_middleware/_canonical.py +106 -0
- mudraid_platform_middleware/_contract.py +69 -0
- mudraid_platform_middleware/_jwks_client.py +295 -0
- mudraid_platform_middleware/_jwt_validator.py +158 -0
- mudraid_platform_middleware/_path.py +201 -0
- mudraid_platform_middleware/_route_matcher.py +115 -0
- mudraid_platform_middleware/_v2_control_loop.py +442 -0
- mudraid_platform_middleware/_yaml_loader.py +240 -0
- mudraid_platform_middleware/decide_client.py +762 -0
- mudraid_platform_middleware/exceptions.py +85 -0
- mudraid_platform_middleware/middleware.py +667 -0
- mudraid_platform_middleware/py.typed +0 -0
- mudraid_platform_middleware/v2.py +283 -0
- mudraid_platform_middleware-1.1.0.dist-info/METADATA +677 -0
- mudraid_platform_middleware-1.1.0.dist-info/RECORD +19 -0
- mudraid_platform_middleware-1.1.0.dist-info/WHEEL +4 -0
- mudraid_platform_middleware-1.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""mudraid-middleware — FastAPI/Starlette middleware for MudraID.
|
|
2
|
+
|
|
3
|
+
Drop-in scope enforcement for platforms registered with the MudraID trust
|
|
4
|
+
layer. Two mutually exclusive modes:
|
|
5
|
+
|
|
6
|
+
- ``mode="v1"`` (default) verifies MudraID-issued JWTs against the route
|
|
7
|
+
scopes declared in ``mudraid_scopes.yaml``;
|
|
8
|
+
- ``mode="v2"`` runs the portable enforcement control loop — reserved header
|
|
9
|
+
strip, bounded JSON-RPC framing, exact canonical action resolution and a
|
|
10
|
+
live, deny-closed ``/decide`` call.
|
|
11
|
+
|
|
12
|
+
See :class:`MudraIDMiddleware` for the per-request flow and
|
|
13
|
+
:class:`~mudraid_platform_middleware.v2.V2Config` for V2 configuration.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from mudraid_platform_middleware.decide_client import HttpDecideClient
|
|
17
|
+
from mudraid_platform_middleware.exceptions import (
|
|
18
|
+
MudraIDInvalidTokenError,
|
|
19
|
+
MudraIDJwksError,
|
|
20
|
+
MudraIDMiddlewareError,
|
|
21
|
+
MudraIDScopesYamlError,
|
|
22
|
+
)
|
|
23
|
+
from mudraid_platform_middleware.middleware import MudraIDMiddleware
|
|
24
|
+
from mudraid_platform_middleware.v2 import (
|
|
25
|
+
DecideClient,
|
|
26
|
+
DecideContext,
|
|
27
|
+
DecideResult,
|
|
28
|
+
V2Config,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"MudraIDMiddleware",
|
|
33
|
+
"MudraIDMiddlewareError",
|
|
34
|
+
"MudraIDScopesYamlError",
|
|
35
|
+
"MudraIDJwksError",
|
|
36
|
+
"MudraIDInvalidTokenError",
|
|
37
|
+
# V2 mode
|
|
38
|
+
"V2Config",
|
|
39
|
+
"DecideClient",
|
|
40
|
+
"DecideContext",
|
|
41
|
+
"HttpDecideClient",
|
|
42
|
+
"DecideResult",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Single source of truth is pyproject.toml (issue #116): read the installed
|
|
46
|
+
# package metadata, falling back to the packaged literal for a source checkout
|
|
47
|
+
# where the distribution isn't installed. Keep the fallback equal to pyproject's
|
|
48
|
+
# version so the two never diverge again.
|
|
49
|
+
from importlib.metadata import PackageNotFoundError
|
|
50
|
+
from importlib.metadata import version as _pkg_version
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
__version__ = _pkg_version("mudraid-platform-middleware")
|
|
54
|
+
except PackageNotFoundError: # source checkout, not pip-installed
|
|
55
|
+
__version__ = "1.1.0"
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Signed-bundle verification — nothing served is trusted before this passes.
|
|
2
|
+
|
|
3
|
+
The adapter channel serves a bundle describing which surface is protected and
|
|
4
|
+
which tool names map to which canonical action. That description decides whether
|
|
5
|
+
a request is enforced at all, so it is verified BEFORE any of it is trusted:
|
|
6
|
+
|
|
7
|
+
1. shape and type checks on the served envelope;
|
|
8
|
+
2. canonical re-serialization of the payload (the same bytes the control plane
|
|
9
|
+
hashed and signed);
|
|
10
|
+
3. ``payload_digest == SHA-256(canonical(payload))``;
|
|
11
|
+
4. ``signature == HMAC-SHA256(signing secret, canonical(payload))``, compared
|
|
12
|
+
in constant time;
|
|
13
|
+
5. contract checks — schema version, surface binding, matcher kind (exact
|
|
14
|
+
only, never fuzzy), the deny-closed evaluation contract;
|
|
15
|
+
6. monotonic version rules against the currently active bundle.
|
|
16
|
+
|
|
17
|
+
ANY failure refuses the bundle. The caller keeps the last valid bundle active,
|
|
18
|
+
and if none exists the protected surface fails CLOSED. An unsigned or tampered
|
|
19
|
+
bundle is never activated — there is no "best effort" acceptance path, because a
|
|
20
|
+
bundle is exactly the artifact an attacker would want to edit.
|
|
21
|
+
|
|
22
|
+
This mirrors ``kong/plugins/mudraid-enforce/bundle.lua`` check for check. The two
|
|
23
|
+
adapters must refuse the same bundles for the same reasons, or "portable
|
|
24
|
+
enforcement" is a claim with a gap in it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import hashlib
|
|
31
|
+
import hmac
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from mudraid_platform_middleware._canonical import (
|
|
37
|
+
CanonicalizationError,
|
|
38
|
+
canonical_json_bytes,
|
|
39
|
+
sha256_hex,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = ["BundleRefused", "VerifiedBundle", "verify_bundle"]
|
|
43
|
+
|
|
44
|
+
#: Bundle schema versions this adapter implements. An unknown version is
|
|
45
|
+
#: refused, never "best effort" parsed. Tracks ``BUNDLE_SCHEMA_VERSION`` in
|
|
46
|
+
#: ``services/platform-integration-service/app/application/bundle_compiler.py``.
|
|
47
|
+
SUPPORTED_SCHEMA_VERSIONS: frozenset[str] = frozenset({"1.0"})
|
|
48
|
+
|
|
49
|
+
#: The only evaluation contract this adapter implements: live, decide-required,
|
|
50
|
+
#: deny-closed, forward-once. A bundle declaring anything else (a future
|
|
51
|
+
#: snapshot mode, say) is refused until this adapter ships verified support for
|
|
52
|
+
#: it — never silently downgraded to something weaker.
|
|
53
|
+
_REQUIRED_EVALUATION: dict[str, str] = {
|
|
54
|
+
"mode": "live",
|
|
55
|
+
"on_timeout": "deny",
|
|
56
|
+
"on_error": "deny",
|
|
57
|
+
"on_unmapped_action": "deny",
|
|
58
|
+
"on_stale_bundle": "deny",
|
|
59
|
+
"forward": "once",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#: Surface fields the adapter forwards verbatim on ``/decide``. A grant is bound
|
|
63
|
+
#: to the exact environment and canonical resource, so a bundle omitting them
|
|
64
|
+
#: describes a surface on which every protected request deny-closes. That is
|
|
65
|
+
#: refused at ACTIVATION rather than discovered per request.
|
|
66
|
+
_REQUIRED_SURFACE_FIELDS = ("platform_id", "environment", "canonical_resource_uri")
|
|
67
|
+
|
|
68
|
+
#: Bounded action-name length, matching the matcher's ``MAX_TOOL_NAME_LEN``.
|
|
69
|
+
MAX_TOOL_NAME_LEN = 512
|
|
70
|
+
|
|
71
|
+
_HEX64 = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BundleRefused(Exception):
|
|
75
|
+
"""A served bundle failed verification and must not be activated.
|
|
76
|
+
|
|
77
|
+
``code`` is the stable typed reason (``BUNDLE_SIGNATURE_INVALID`` and
|
|
78
|
+
friends), matching the Lua plugin's vocabulary so operators reading either
|
|
79
|
+
adapter's logs see the same word for the same defect.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, code: str, detail: str = "") -> None:
|
|
83
|
+
super().__init__(f"{code}: {detail}" if detail else code)
|
|
84
|
+
self.code = code
|
|
85
|
+
self.detail = detail
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class VerifiedBundle:
|
|
90
|
+
"""A bundle that passed every check, and only then."""
|
|
91
|
+
|
|
92
|
+
bundle_version: int
|
|
93
|
+
payload_digest: str
|
|
94
|
+
content_digest: str
|
|
95
|
+
signing_key_id: str
|
|
96
|
+
surface: dict[str, Any]
|
|
97
|
+
actions: dict[str, dict[str, Any]]
|
|
98
|
+
strip_prefixes: tuple[str, ...]
|
|
99
|
+
no_change: bool = False
|
|
100
|
+
|
|
101
|
+
def resolve(self, tool_name: str) -> dict[str, Any] | None:
|
|
102
|
+
"""The EXACT canonical action for ``tool_name``, or ``None``.
|
|
103
|
+
|
|
104
|
+
Exact and case-sensitive — never fuzzy, prefix or regex. An unmapped
|
|
105
|
+
name resolves to ``None``, which the control loop turns into a deny.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
108
|
+
return None
|
|
109
|
+
if len(tool_name.encode("utf-8")) > MAX_TOOL_NAME_LEN:
|
|
110
|
+
return None
|
|
111
|
+
return self.actions.get(tool_name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_bound_string(value: Any) -> bool:
|
|
115
|
+
"""A usable, non-empty, non-whitespace string.
|
|
116
|
+
|
|
117
|
+
JSON ``null`` decodes to ``None``, and the control plane still emits it for a
|
|
118
|
+
surface row whose ``canonical_resource_uri`` was never backfilled — so the
|
|
119
|
+
check is an explicit type test, not a truthiness test.
|
|
120
|
+
"""
|
|
121
|
+
return isinstance(value, str) and value.strip() != ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_action_index(actions: Any) -> dict[str, dict[str, Any]]:
|
|
125
|
+
"""Index the action corpus by exact tool name, refusing ambiguity.
|
|
126
|
+
|
|
127
|
+
A duplicate ``tool_name`` is rejected at BUILD time, so runtime can never
|
|
128
|
+
resolve an overlap by iteration order.
|
|
129
|
+
"""
|
|
130
|
+
if not isinstance(actions, list) or not actions:
|
|
131
|
+
raise BundleRefused("BUNDLE_CONTENT_INVALID", "matcher.actions is empty")
|
|
132
|
+
index: dict[str, dict[str, Any]] = {}
|
|
133
|
+
for action in actions:
|
|
134
|
+
if not isinstance(action, dict):
|
|
135
|
+
raise BundleRefused("BUNDLE_ACTION_TOOL_NAME_INVALID", "action is not an object")
|
|
136
|
+
name = action.get("tool_name")
|
|
137
|
+
if not isinstance(name, str) or name == "" or len(name.encode("utf-8")) > MAX_TOOL_NAME_LEN:
|
|
138
|
+
raise BundleRefused("BUNDLE_ACTION_TOOL_NAME_INVALID", "tool_name is not usable")
|
|
139
|
+
if name in index:
|
|
140
|
+
raise BundleRefused("BUNDLE_MATCHER_AMBIGUOUS", f"duplicate tool_name {name!r}")
|
|
141
|
+
index[name] = action
|
|
142
|
+
return index
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_surface(surface: Any) -> None:
|
|
146
|
+
if not isinstance(surface, dict):
|
|
147
|
+
raise BundleRefused("BUNDLE_SURFACE_UNBOUND", "surface missing")
|
|
148
|
+
for key in _REQUIRED_SURFACE_FIELDS:
|
|
149
|
+
if not _is_bound_string(surface.get(key)):
|
|
150
|
+
raise BundleRefused("BUNDLE_SURFACE_UNBOUND", f"surface.{key} is not a bound value")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_evaluation(evaluation: Any) -> None:
|
|
154
|
+
if not isinstance(evaluation, dict):
|
|
155
|
+
raise BundleRefused("BUNDLE_EVALUATION_UNSUPPORTED", "evaluation missing")
|
|
156
|
+
for key, expected in _REQUIRED_EVALUATION.items():
|
|
157
|
+
if evaluation.get(key) != expected:
|
|
158
|
+
raise BundleRefused(
|
|
159
|
+
"BUNDLE_EVALUATION_UNSUPPORTED", f"evaluation.{key} must be {expected!r}"
|
|
160
|
+
)
|
|
161
|
+
# `is not True` rather than a truthiness test: 1 is not the contract.
|
|
162
|
+
if evaluation.get("decide_required") is not True:
|
|
163
|
+
raise BundleRefused(
|
|
164
|
+
"BUNDLE_EVALUATION_UNSUPPORTED", "evaluation.decide_required must be true"
|
|
165
|
+
)
|
|
166
|
+
if evaluation.get("retry_forwarded_request") is not False:
|
|
167
|
+
raise BundleRefused(
|
|
168
|
+
"BUNDLE_EVALUATION_UNSUPPORTED", "evaluation.retry_forwarded_request must be false"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
#: Pinned, not negotiated. Mirrors ``bundle_signature.py`` on the control plane
|
|
173
|
+
#: and ``bundle.lua`` in Kong — three implementations, one set of constants they
|
|
174
|
+
#: are each checked against.
|
|
175
|
+
BUNDLE_SIGNATURE_PROFILE = "mudraid.bundle.signature/1"
|
|
176
|
+
BUNDLE_SIGNATURE_ALGORITHM = "RS256"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _verify_asymmetric(
|
|
180
|
+
fetched: dict,
|
|
181
|
+
*,
|
|
182
|
+
verification_keys: dict[str, str] | None,
|
|
183
|
+
expected_platform_id: str | None,
|
|
184
|
+
expected_environment: str | None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Verify the RS256 bundle signature, or raise ``BundleRefused``.
|
|
187
|
+
|
|
188
|
+
The claims are verified as the signer serialized them and only THEN compared
|
|
189
|
+
against the bundle in hand. A signature that verifies proves MudraID
|
|
190
|
+
produced those claims; it says nothing about whether they describe this
|
|
191
|
+
bundle, which is what the digest and binding comparisons establish.
|
|
192
|
+
"""
|
|
193
|
+
if not verification_keys:
|
|
194
|
+
raise BundleRefused(
|
|
195
|
+
"BUNDLE_VERIFICATION_KEYS_UNAVAILABLE",
|
|
196
|
+
"the bundle carries a signature but no verification keys are available",
|
|
197
|
+
)
|
|
198
|
+
if fetched.get("signature_profile") != BUNDLE_SIGNATURE_PROFILE:
|
|
199
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "unsupported signature profile")
|
|
200
|
+
# Compared against the pinned constant, never read out of the signature and
|
|
201
|
+
# used — the ``alg: none`` lesson.
|
|
202
|
+
if fetched.get("signature_algorithm") != BUNDLE_SIGNATURE_ALGORITHM:
|
|
203
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "unsupported signature algorithm")
|
|
204
|
+
|
|
205
|
+
key_id = fetched.get("signature_key_id")
|
|
206
|
+
if not isinstance(key_id, str) or not key_id:
|
|
207
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature names no key")
|
|
208
|
+
public_pem = verification_keys.get(key_id)
|
|
209
|
+
if not public_pem:
|
|
210
|
+
# Unknown or retired. A key that is no longer published is a key whose
|
|
211
|
+
# signatures are no longer trusted.
|
|
212
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature names an unknown key")
|
|
213
|
+
|
|
214
|
+
claims = fetched.get("signature_claims")
|
|
215
|
+
if not isinstance(claims, dict):
|
|
216
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature carries no claims")
|
|
217
|
+
if claims.get("key_id") != key_id:
|
|
218
|
+
raise BundleRefused(
|
|
219
|
+
"BUNDLE_SIGNATURE_INVALID", "claims name a different key than the signature"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
encoded = fetched.get("signature_value")
|
|
223
|
+
if not isinstance(encoded, str) or not encoded:
|
|
224
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature is absent")
|
|
225
|
+
try:
|
|
226
|
+
raw = base64.b64decode(encoded, validate=True)
|
|
227
|
+
except (ValueError, TypeError) as exc:
|
|
228
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature is not valid base64") from exc
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
from cryptography.exceptions import InvalidSignature
|
|
232
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
233
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
234
|
+
except ImportError as exc: # pragma: no cover - cryptography is a hard dep
|
|
235
|
+
# A missing verifier must never read as a valid signature.
|
|
236
|
+
raise BundleRefused(
|
|
237
|
+
"BUNDLE_SIGNATURE_INVALID", "asymmetric verification is unavailable"
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
key = serialization.load_pem_public_key(public_pem.encode("utf-8"))
|
|
242
|
+
except (ValueError, TypeError) as exc:
|
|
243
|
+
raise BundleRefused(
|
|
244
|
+
"BUNDLE_SIGNATURE_INVALID", "verification key could not be loaded"
|
|
245
|
+
) from exc
|
|
246
|
+
if not isinstance(key, rsa.RSAPublicKey):
|
|
247
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "verification key is not RSA")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
key.verify(raw, canonical_json_bytes(claims), padding.PKCS1v15(), hashes.SHA256())
|
|
251
|
+
except InvalidSignature as exc:
|
|
252
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature does not verify") from exc
|
|
253
|
+
|
|
254
|
+
# Only now are the claims trustworthy enough to compare.
|
|
255
|
+
if claims.get("payload_digest") != fetched.get("payload_digest"):
|
|
256
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "signature does not cover this payload")
|
|
257
|
+
if claims.get("bundle_version") != fetched.get("bundle_version"):
|
|
258
|
+
raise BundleRefused(
|
|
259
|
+
"BUNDLE_SIGNATURE_INVALID", "signature covers a different bundle version"
|
|
260
|
+
)
|
|
261
|
+
if expected_platform_id and claims.get("platform_id") != expected_platform_id:
|
|
262
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "bundle is bound to another platform")
|
|
263
|
+
if expected_environment and claims.get("environment") != expected_environment:
|
|
264
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "bundle is bound to another environment")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def verify_bundle(
|
|
268
|
+
fetched: Any,
|
|
269
|
+
*,
|
|
270
|
+
secret: str | None,
|
|
271
|
+
active: VerifiedBundle | None = None,
|
|
272
|
+
verification_keys: dict[str, str] | None = None,
|
|
273
|
+
expected_platform_id: str | None = None,
|
|
274
|
+
expected_environment: str | None = None,
|
|
275
|
+
) -> VerifiedBundle:
|
|
276
|
+
"""Verify one fetched bundle, or refuse it.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
fetched: the decoded ``GET /api/v1/internal/enforcement/bundle`` response.
|
|
280
|
+
secret: the adapter's bundle signing secret. Absent or empty means
|
|
281
|
+
nothing can be verified, which is refused — never "trust unsigned".
|
|
282
|
+
active: the currently active bundle, for the monotonic version rules.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
BundleRefused: on any failure, with a typed ``code``.
|
|
286
|
+
"""
|
|
287
|
+
if not isinstance(fetched, dict):
|
|
288
|
+
raise BundleRefused("BUNDLE_RESPONSE_INVALID", "response is not an object")
|
|
289
|
+
|
|
290
|
+
version = fetched.get("bundle_version")
|
|
291
|
+
# `bool` is a subclass of `int`, so an explicit exclusion is required or
|
|
292
|
+
# `True` would be accepted as version 1.
|
|
293
|
+
if isinstance(version, bool) or not isinstance(version, int) or version < 1:
|
|
294
|
+
raise BundleRefused("BUNDLE_RESPONSE_INVALID", "bundle_version is not a positive integer")
|
|
295
|
+
if fetched.get("schema_version") not in SUPPORTED_SCHEMA_VERSIONS:
|
|
296
|
+
raise BundleRefused(
|
|
297
|
+
"BUNDLE_SCHEMA_UNSUPPORTED",
|
|
298
|
+
f"schema_version {fetched.get('schema_version')!r} is not supported",
|
|
299
|
+
)
|
|
300
|
+
payload = fetched.get("payload")
|
|
301
|
+
if not isinstance(payload, dict):
|
|
302
|
+
raise BundleRefused("BUNDLE_RESPONSE_INVALID", "payload missing")
|
|
303
|
+
payload_digest = fetched.get("payload_digest")
|
|
304
|
+
if not isinstance(payload_digest, str) or not _HEX64.match(payload_digest):
|
|
305
|
+
raise BundleRefused("BUNDLE_RESPONSE_INVALID", "payload_digest is not sha256 hex")
|
|
306
|
+
# The HMAC fields are required only WHEN PRESENT, which is not a weakening.
|
|
307
|
+
#
|
|
308
|
+
# These two were unconditional, and that made the HMAC retirement incomplete
|
|
309
|
+
# in a way no signature test would have found: the block below stopped
|
|
310
|
+
# requiring the SECRET, while this stayed requiring the FIELD. The moment
|
|
311
|
+
# the control plane stops emitting `signature`, every bundle — including one
|
|
312
|
+
# carrying a perfectly good RS256 signature — is refused here as
|
|
313
|
+
# BUNDLE_RESPONSE_INVALID, before any signature logic runs. A malformed
|
|
314
|
+
# envelope and an asymmetrically-signed bundle would report as the same
|
|
315
|
+
# thing, and a shipped SDK that "configures no secret and verifies the RS256
|
|
316
|
+
# signature only" could never actually accept one.
|
|
317
|
+
#
|
|
318
|
+
# Present-but-malformed is still a refusal, and that is the half that must
|
|
319
|
+
# not move: absence and invalidity are different facts (see below).
|
|
320
|
+
signature = fetched.get("signature")
|
|
321
|
+
has_hmac_signature = signature is not None
|
|
322
|
+
signing_key_id = fetched.get("signing_key_id")
|
|
323
|
+
if has_hmac_signature:
|
|
324
|
+
if not isinstance(signature, str) or not _HEX64.match(signature):
|
|
325
|
+
raise BundleRefused("BUNDLE_RESPONSE_INVALID", "signature is not hmac-sha256 hex")
|
|
326
|
+
if not isinstance(signing_key_id, str) or signing_key_id == "":
|
|
327
|
+
raise BundleRefused(
|
|
328
|
+
"BUNDLE_RESPONSE_INVALID",
|
|
329
|
+
"signature is present but signing_key_id is missing; nothing names "
|
|
330
|
+
"the key it was produced with",
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
# With no HMAC signature there is no HMAC key, so the key that
|
|
334
|
+
# authenticated this bundle is the asymmetric one. Reporting the absent
|
|
335
|
+
# HMAC field would leave every downstream log and acknowledgement
|
|
336
|
+
# unable to say which key vouched for the bundle it applied.
|
|
337
|
+
signing_key_id = fetched.get("signature_key_id")
|
|
338
|
+
|
|
339
|
+
# The SIGNED payload is authoritative; the unsigned wrapper must agree with
|
|
340
|
+
# it, or the served envelope is describing a different bundle than the one
|
|
341
|
+
# the signature covers.
|
|
342
|
+
if payload.get("schema_version") != fetched.get("schema_version"):
|
|
343
|
+
raise BundleRefused(
|
|
344
|
+
"BUNDLE_ENVELOPE_MISMATCH", "payload.schema_version disagrees with response"
|
|
345
|
+
)
|
|
346
|
+
if payload.get("bundle_version") != version:
|
|
347
|
+
raise BundleRefused(
|
|
348
|
+
"BUNDLE_ENVELOPE_MISMATCH", "payload.bundle_version disagrees with response"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
canon = canonical_json_bytes(payload)
|
|
353
|
+
except CanonicalizationError as exc:
|
|
354
|
+
raise BundleRefused("BUNDLE_CANONICALIZATION_FAILED", str(exc)) from exc
|
|
355
|
+
|
|
356
|
+
# Digest BEFORE signature, deliberately: a digest mismatch is tampering or
|
|
357
|
+
# corruption regardless of key material, and deserves its own typed fact
|
|
358
|
+
# rather than being reported as a signature failure.
|
|
359
|
+
if hashlib.sha256(canon).hexdigest() != payload_digest:
|
|
360
|
+
raise BundleRefused(
|
|
361
|
+
"BUNDLE_DIGEST_MISMATCH", "payload_digest does not match canonical payload"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# ── Which signature must this verifier check? ────────────────────────────
|
|
365
|
+
#
|
|
366
|
+
# A CUSTOMER adapter never held the HMAC secret and never will — handing it
|
|
367
|
+
# over would let the customer mint bundles for their own surface, which is
|
|
368
|
+
# the whole reason for moving to asymmetric signatures. So the shipped SDK
|
|
369
|
+
# configures no secret and verifies the RS256 signature only.
|
|
370
|
+
#
|
|
371
|
+
# MudraID's own deployed gateway is the other case: it holds the secret and
|
|
372
|
+
# its bundles may predate asymmetric signing, so it verifies the HMAC and
|
|
373
|
+
# treats the asymmetric signature as an additional check when present.
|
|
374
|
+
#
|
|
375
|
+
# What must NEVER happen is BOTH being absent. That is a bundle nothing
|
|
376
|
+
# verified, and accepting it would make every check above decorative.
|
|
377
|
+
has_secret = isinstance(secret, str) and secret != ""
|
|
378
|
+
has_asymmetric = fetched.get("signature_value") is not None
|
|
379
|
+
|
|
380
|
+
if not has_asymmetric and not (has_secret and has_hmac_signature):
|
|
381
|
+
# Nothing here can be authenticated. The detail distinguishes the ways
|
|
382
|
+
# to arrive, because "configure a secret" is useless advice to someone
|
|
383
|
+
# who already has one and received a bundle carrying no signature.
|
|
384
|
+
if has_secret:
|
|
385
|
+
detail = (
|
|
386
|
+
"the bundle carries neither an asymmetric signature nor an HMAC "
|
|
387
|
+
"signature, so the configured signing secret had nothing to check; "
|
|
388
|
+
"unsigned trust is refused"
|
|
389
|
+
)
|
|
390
|
+
elif has_hmac_signature:
|
|
391
|
+
detail = (
|
|
392
|
+
"the bundle carries no asymmetric signature and no signing secret is "
|
|
393
|
+
"configured to check its HMAC; unsigned trust is refused"
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
detail = (
|
|
397
|
+
"the bundle carries no signature of any kind and no signing secret is "
|
|
398
|
+
"configured; unsigned trust is refused"
|
|
399
|
+
)
|
|
400
|
+
raise BundleRefused("BUNDLE_SIGNING_SECRET_UNCONFIGURED", detail)
|
|
401
|
+
|
|
402
|
+
# BOTH halves are required to CHECK an HMAC — a secret to check with, and a
|
|
403
|
+
# signature to check. A configured secret alone is not a check.
|
|
404
|
+
#
|
|
405
|
+
# The `has_hmac_signature` half is what lets a deployment that still has the
|
|
406
|
+
# secret configured keep working once the control plane stops emitting HMAC.
|
|
407
|
+
# Without it, compare_digest against a None signature raises or fails, and
|
|
408
|
+
# every RS256-signed bundle is refused as an HMAC failure — a deny
|
|
409
|
+
# attributed to the wrong signature entirely.
|
|
410
|
+
#
|
|
411
|
+
# Not a downgrade path: stripping the HMAC does not help an attacker,
|
|
412
|
+
# because whatever remains must still verify on its own, and if nothing
|
|
413
|
+
# does, the refusal above already fired.
|
|
414
|
+
if has_secret and has_hmac_signature:
|
|
415
|
+
expected = hmac.new(secret.encode("utf-8"), canon, "sha256").hexdigest()
|
|
416
|
+
# compare_digest, not `!=`: this is an authentication tag, and a plain
|
|
417
|
+
# comparison short-circuits on the first differing byte.
|
|
418
|
+
if not hmac.compare_digest(expected, signature):
|
|
419
|
+
raise BundleRefused("BUNDLE_SIGNATURE_INVALID", "HMAC signature verification failed")
|
|
420
|
+
|
|
421
|
+
# ── The asymmetric signature ─────────────────────────────────────────────
|
|
422
|
+
#
|
|
423
|
+
# ABSENCE AND INVALIDITY ARE DIFFERENT FACTS. A bundle published before
|
|
424
|
+
# asymmetric signing existed carries none, and must still be accepted on its
|
|
425
|
+
# HMAC — that is what makes the migration window a window. A bundle that
|
|
426
|
+
# CARRIES one which does not verify is never treated as legacy: if
|
|
427
|
+
# invalidity fell back to the HMAC, anyone holding the shared secret could
|
|
428
|
+
# corrupt a single field and downgrade every bundle to the weaker check.
|
|
429
|
+
#
|
|
430
|
+
# Present-but-invalid always refuses, and the reason is typed separately so
|
|
431
|
+
# an operator can tell a downgrade attempt from an unsigned legacy bundle.
|
|
432
|
+
if fetched.get("signature_value") is not None:
|
|
433
|
+
_verify_asymmetric(
|
|
434
|
+
fetched,
|
|
435
|
+
verification_keys=verification_keys,
|
|
436
|
+
expected_platform_id=expected_platform_id,
|
|
437
|
+
expected_environment=expected_environment,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
content = payload.get("content")
|
|
441
|
+
if not isinstance(content, dict):
|
|
442
|
+
raise BundleRefused("BUNDLE_CONTENT_INVALID", "payload.content missing")
|
|
443
|
+
_check_surface(content.get("surface"))
|
|
444
|
+
_check_evaluation(content.get("evaluation"))
|
|
445
|
+
|
|
446
|
+
matcher = content.get("matcher")
|
|
447
|
+
if not isinstance(matcher, dict) or matcher.get("kind") != "mcp_tool_exact":
|
|
448
|
+
# An unknown matcher kind must never degrade to fuzzy or partial
|
|
449
|
+
# matching — that would silently widen what counts as a mapped action.
|
|
450
|
+
kind = matcher.get("kind") if isinstance(matcher, dict) else None
|
|
451
|
+
raise BundleRefused("BUNDLE_MATCHER_UNSUPPORTED", f"matcher.kind {kind!r} is not supported")
|
|
452
|
+
index = _build_action_index(matcher.get("actions"))
|
|
453
|
+
|
|
454
|
+
# Monotonic version rules: a LOWER version is refused (a control-plane
|
|
455
|
+
# rollback republishes a HIGHER number, never a lower one, so a lower number
|
|
456
|
+
# is a rollback attack or a stale mirror); the same version with different
|
|
457
|
+
# bytes is a conflict; the same version with the same digest is a no-op.
|
|
458
|
+
no_change = False
|
|
459
|
+
if active is not None:
|
|
460
|
+
if version < active.bundle_version:
|
|
461
|
+
raise BundleRefused(
|
|
462
|
+
"BUNDLE_VERSION_REGRESSION",
|
|
463
|
+
f"served version {version} < active version {active.bundle_version}",
|
|
464
|
+
)
|
|
465
|
+
if version == active.bundle_version:
|
|
466
|
+
if payload_digest != active.payload_digest:
|
|
467
|
+
raise BundleRefused(
|
|
468
|
+
"BUNDLE_VERSION_CONFLICT", "same bundle_version with a different payload_digest"
|
|
469
|
+
)
|
|
470
|
+
no_change = True
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
content_digest = sha256_hex(content)
|
|
474
|
+
except CanonicalizationError as exc: # pragma: no cover - payload already canonicalized
|
|
475
|
+
raise BundleRefused("BUNDLE_CANONICALIZATION_FAILED", str(exc)) from exc
|
|
476
|
+
|
|
477
|
+
trusted = content.get("trusted_context")
|
|
478
|
+
prefixes: tuple[str, ...] = ()
|
|
479
|
+
if isinstance(trusted, dict):
|
|
480
|
+
raw = trusted.get("strip_request_header_prefixes")
|
|
481
|
+
if isinstance(raw, list):
|
|
482
|
+
prefixes = tuple(p for p in raw if isinstance(p, str) and p)
|
|
483
|
+
|
|
484
|
+
return VerifiedBundle(
|
|
485
|
+
bundle_version=version,
|
|
486
|
+
payload_digest=payload_digest,
|
|
487
|
+
content_digest=content_digest,
|
|
488
|
+
signing_key_id=signing_key_id,
|
|
489
|
+
surface=dict(content["surface"]),
|
|
490
|
+
actions=index,
|
|
491
|
+
strip_prefixes=prefixes,
|
|
492
|
+
no_change=no_change,
|
|
493
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Canonical JSON bytes — the exact serialization the bundle signer hashes.
|
|
2
|
+
|
|
3
|
+
A signed bundle's ``payload_digest`` and ``signature`` are computed over ONE
|
|
4
|
+
serialization of the payload, and a verifier that reproduces different bytes
|
|
5
|
+
rejects a valid bundle (or, far worse, accepts a tampered one because it
|
|
6
|
+
normalized the difference away). So the canonical form is not a preference here;
|
|
7
|
+
it is the thing being verified.
|
|
8
|
+
|
|
9
|
+
The control plane defines it in
|
|
10
|
+
``services/platform-integration-service/app/application/bundle_compiler.py``::
|
|
11
|
+
|
|
12
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
13
|
+
|
|
14
|
+
which this module reproduces exactly. Note what that means for a Python
|
|
15
|
+
verifier: the signer IS Python, so canonicalization here is the reference form
|
|
16
|
+
rather than an imitation of it. The Kong plugin's ``canonical.lua`` is the one
|
|
17
|
+
carrying the burden of matching — it hand-rolls key sorting, ``\\uXXXX``
|
|
18
|
+
escaping and surrogate pairs to land on these same bytes.
|
|
19
|
+
|
|
20
|
+
What this module adds over a bare ``json.dumps`` is REFUSAL. A digest computed
|
|
21
|
+
over a "best effort" serialization would silently accept tampering, so a value
|
|
22
|
+
that cannot be canonicalized with certainty raises rather than being
|
|
23
|
+
approximated:
|
|
24
|
+
|
|
25
|
+
- ``NaN`` / ``Infinity`` / ``-Infinity`` — accepted by Python's JSON parser by
|
|
26
|
+
default and re-emitted as bare tokens that are not JSON at all. The signer
|
|
27
|
+
never produces them, so their presence means the response is not a bundle
|
|
28
|
+
this verifier can speak about.
|
|
29
|
+
- non-integer floats — the bundle contract carries only integers (versions,
|
|
30
|
+
revisions, counts), and a float has no cross-language canonical form.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
__all__ = ["CanonicalizationError", "canonical_json_bytes", "loads_strict", "sha256_hex"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CanonicalizationError(ValueError):
|
|
43
|
+
"""A value cannot be canonicalized with certainty, so it is refused."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reject_constant(token: str) -> Any:
|
|
47
|
+
raise CanonicalizationError(
|
|
48
|
+
f"JSON document contains the non-standard constant {token!r}; "
|
|
49
|
+
"a signed bundle never carries one"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def loads_strict(raw: str | bytes) -> Any:
|
|
54
|
+
"""Parse JSON, refusing ``NaN``/``Infinity``/``-Infinity``.
|
|
55
|
+
|
|
56
|
+
Python's parser accepts those by default. They cannot appear in anything the
|
|
57
|
+
signer produced, and they do not round-trip through any other JSON
|
|
58
|
+
implementation, so a document containing one is refused at the door rather
|
|
59
|
+
than carried into a digest.
|
|
60
|
+
"""
|
|
61
|
+
return json.loads(raw, parse_constant=_reject_constant)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check_canonicalizable(value: Any) -> None:
|
|
65
|
+
"""Walk a decoded value, refusing anything without a certain canonical form."""
|
|
66
|
+
if isinstance(value, bool) or value is None:
|
|
67
|
+
return
|
|
68
|
+
if isinstance(value, int):
|
|
69
|
+
return
|
|
70
|
+
if isinstance(value, float):
|
|
71
|
+
# Reached only via a literal like 1.5; NaN/Infinity are already refused
|
|
72
|
+
# by loads_strict. Integer-valued floats are refused too: `1.0` serializes
|
|
73
|
+
# as "1.0" here and as "1" from an int, so accepting it would mean two
|
|
74
|
+
# different byte strings for one logical value.
|
|
75
|
+
raise CanonicalizationError(
|
|
76
|
+
f"non-integer number {value!r} has no canonical form across languages"
|
|
77
|
+
)
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
return
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
for key, item in value.items():
|
|
82
|
+
if not isinstance(key, str):
|
|
83
|
+
raise CanonicalizationError(f"non-string object key {key!r}")
|
|
84
|
+
_check_canonicalizable(item)
|
|
85
|
+
return
|
|
86
|
+
if isinstance(value, (list, tuple)):
|
|
87
|
+
for item in value:
|
|
88
|
+
_check_canonicalizable(item)
|
|
89
|
+
return
|
|
90
|
+
raise CanonicalizationError(f"unsupported value type: {type(value).__name__}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
94
|
+
"""The exact bytes the control plane hashed and signed.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
CanonicalizationError: the value contains something with no certain
|
|
98
|
+
canonical form. Callers treat that as an invalid bundle.
|
|
99
|
+
"""
|
|
100
|
+
_check_canonicalizable(value)
|
|
101
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sha256_hex(value: Any) -> str:
|
|
105
|
+
"""SHA-256 of the canonical bytes, lowercase hex — the signer's digest."""
|
|
106
|
+
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|