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.
@@ -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()