constellation-node-sdk 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. constellation_node_sdk/__init__.py +123 -0
  2. constellation_node_sdk/gate/__init__.py +47 -0
  3. constellation_node_sdk/gate/client.py +108 -0
  4. constellation_node_sdk/gate/config.py +153 -0
  5. constellation_node_sdk/gate/errors.py +17 -0
  6. constellation_node_sdk/gate/policy.py +70 -0
  7. constellation_node_sdk/gate/registration.py +144 -0
  8. constellation_node_sdk/orchestrator/__init__.py +3 -0
  9. constellation_node_sdk/orchestrator/base.py +57 -0
  10. constellation_node_sdk/orchestrator/merge.py +47 -0
  11. constellation_node_sdk/orchestrator/packet_builder.py +49 -0
  12. constellation_node_sdk/orchestrator/retry.py +61 -0
  13. constellation_node_sdk/orchestrator/state.py +78 -0
  14. constellation_node_sdk/orchestrator/step_executor.py +87 -0
  15. constellation_node_sdk/py.typed +0 -0
  16. constellation_node_sdk/runtime/__init__.py +52 -0
  17. constellation_node_sdk/runtime/app.py +274 -0
  18. constellation_node_sdk/runtime/config.py +307 -0
  19. constellation_node_sdk/runtime/errors.py +112 -0
  20. constellation_node_sdk/runtime/execution.py +249 -0
  21. constellation_node_sdk/runtime/handlers.py +45 -0
  22. constellation_node_sdk/runtime/inbound_policy.py +86 -0
  23. constellation_node_sdk/runtime/lifecycle.py +29 -0
  24. constellation_node_sdk/runtime/observability.py +72 -0
  25. constellation_node_sdk/runtime/preflight.py +122 -0
  26. constellation_node_sdk/security/__init__.py +36 -0
  27. constellation_node_sdk/security/delegation.py +57 -0
  28. constellation_node_sdk/security/errors.py +25 -0
  29. constellation_node_sdk/security/signing.py +121 -0
  30. constellation_node_sdk/security/validation.py +230 -0
  31. constellation_node_sdk/security/verification.py +107 -0
  32. constellation_node_sdk/transport/__init__.py +88 -0
  33. constellation_node_sdk/transport/codec.py +21 -0
  34. constellation_node_sdk/transport/errors.py +41 -0
  35. constellation_node_sdk/transport/hashing.py +73 -0
  36. constellation_node_sdk/transport/hop_trace.py +378 -0
  37. constellation_node_sdk/transport/lineage.py +28 -0
  38. constellation_node_sdk/transport/models.py +447 -0
  39. constellation_node_sdk/transport/packet.py +331 -0
  40. constellation_node_sdk/transport/provenance.py +53 -0
  41. constellation_node_sdk/transport/tenant.py +76 -0
  42. constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
  43. constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
  44. constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
  45. constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Callable
6
+
7
+ from constellation_node_sdk.security.signing import sign_transport_packet
8
+ from constellation_node_sdk.security.validation import validate_transport_packet
9
+ from constellation_node_sdk.transport.hop_trace import make_execution_hop, make_response_hop
10
+ from constellation_node_sdk.transport.packet import TransportPacket
11
+
12
+ from .handlers import get_handler
13
+
14
+
15
+ def _resolve_signing_material(
16
+ *,
17
+ signing_key: bytes | str | None,
18
+ signing_private_key: str | None,
19
+ signing_algorithm: str | None,
20
+ ) -> bytes | str | None:
21
+ if signing_algorithm is None:
22
+ return signing_key if signing_key is not None else signing_private_key
23
+ normalized = signing_algorithm.strip().lower()
24
+ if normalized == "hmac-sha256":
25
+ return signing_key
26
+ if normalized == "ed25519":
27
+ return signing_private_key
28
+ raise ValueError(f"unsupported signing algorithm: {normalized}")
29
+
30
+
31
+ def _build_key_resolver(
32
+ *,
33
+ signing_key: bytes | str | None,
34
+ verifying_keys: dict[str, str] | None,
35
+ ) -> Callable[[str | None], str | bytes | None] | dict[str, str] | None:
36
+ if verifying_keys:
37
+ return verifying_keys
38
+ if signing_key is None:
39
+ return None
40
+ return lambda _key_id: signing_key
41
+
42
+
43
+ async def _invoke_handler(handler: Callable[..., object], packet: TransportPacket) -> object:
44
+ parameters = list(inspect.signature(handler).parameters.values())
45
+
46
+ if len(parameters) == 1:
47
+ candidate = handler(packet)
48
+ elif len(parameters) == 2:
49
+ candidate = handler(packet.tenant.org_id, packet.payload)
50
+ elif len(parameters) == 0:
51
+ candidate = handler()
52
+ else:
53
+ candidate = handler(packet.tenant.org_id, packet.payload, packet)
54
+
55
+ if inspect.isawaitable(candidate):
56
+ return await candidate
57
+ return candidate
58
+
59
+
60
+ def _extract_payload(result: object) -> dict[str, object]:
61
+ if isinstance(result, dict):
62
+ return result
63
+ raise TypeError(f"handler must return dict or TransportPacket, got {type(result)!r}")
64
+
65
+
66
+ async def execute_transport_packet(
67
+ packet: TransportPacket,
68
+ *,
69
+ node_name: str,
70
+ signing_key: bytes | str | None = None,
71
+ signing_private_key: str | None = None,
72
+ signing_key_id: str | None = None,
73
+ signing_algorithm: str | None = None,
74
+ verifying_keys: dict[str, str] | None = None,
75
+ require_signature: bool = False,
76
+ allowed_actions: tuple[str, ...] | None = None,
77
+ allowed_packet_types: tuple[str, ...] | None = None,
78
+ required_idempotency_actions: tuple[str, ...] | None = None,
79
+ replay_enabled: bool = True,
80
+ dev_mode: bool = False,
81
+ verify_hop_signatures: bool = False,
82
+ allowed_clock_skew_seconds: int = 30,
83
+ max_packet_bytes: int = 262_144,
84
+ max_hop_depth: int = 64,
85
+ max_delegation_depth: int = 8,
86
+ max_attachments: int = 32,
87
+ max_attachment_size_bytes: int = 10_485_760,
88
+ allowed_attachment_schemes: tuple[str, ...] = (),
89
+ allow_private_attachment_hosts: bool = False,
90
+ ) -> TransportPacket:
91
+ normalized_node_name = node_name.strip().lower()
92
+ key_resolver = _build_key_resolver(
93
+ signing_key=signing_key,
94
+ verifying_keys=verifying_keys,
95
+ )
96
+
97
+ validate_transport_packet(
98
+ packet,
99
+ key_resolver=key_resolver,
100
+ require_signature=require_signature,
101
+ max_packet_bytes=max_packet_bytes,
102
+ max_hop_depth=max_hop_depth,
103
+ max_delegation_depth=max_delegation_depth,
104
+ max_attachments=max_attachments,
105
+ max_attachment_size_bytes=max_attachment_size_bytes,
106
+ allowed_attachment_schemes=allowed_attachment_schemes,
107
+ allow_private_attachment_hosts=allow_private_attachment_hosts,
108
+ allowed_clock_skew_seconds=allowed_clock_skew_seconds,
109
+ local_node=normalized_node_name,
110
+ allowed_actions=allowed_actions,
111
+ allowed_packet_types=allowed_packet_types,
112
+ required_idempotency_actions=required_idempotency_actions,
113
+ replay_enabled=replay_enabled,
114
+ dev_mode=dev_mode,
115
+ verify_hop_signatures=verify_hop_signatures,
116
+ hop_key_resolver=key_resolver,
117
+ )
118
+
119
+ handler = get_handler(packet.header.action)
120
+ if handler is None:
121
+ raise ValueError(f"no handler registered for action: {packet.header.action}")
122
+
123
+ processing_packet = packet.with_hop(
124
+ make_execution_hop(
125
+ packet=packet,
126
+ node=normalized_node_name,
127
+ action=packet.header.action,
128
+ status="processing",
129
+ )
130
+ )
131
+
132
+ try:
133
+ result = await asyncio.wait_for(
134
+ _invoke_handler(handler, processing_packet),
135
+ timeout=processing_packet.header.timeout_ms / 1000,
136
+ )
137
+ except TimeoutError as exc:
138
+ raise TimeoutError(
139
+ f"handler timeout after {processing_packet.header.timeout_ms}ms"
140
+ ) from exc
141
+
142
+ if isinstance(result, TransportPacket):
143
+ response_packet = result
144
+ else:
145
+ payload = _extract_payload(result)
146
+ response_packet = processing_packet.derive(
147
+ packet_type="response",
148
+ source_node=normalized_node_name,
149
+ destination_node=processing_packet.address.reply_to,
150
+ reply_to=normalized_node_name,
151
+ payload=payload,
152
+ )
153
+
154
+ payload_status = str(response_packet.payload.get("status", "completed")).strip().lower()
155
+ if payload_status not in {
156
+ "received",
157
+ "validated",
158
+ "processing",
159
+ "delegated",
160
+ "completed",
161
+ "failed",
162
+ }:
163
+ payload_status = "completed"
164
+
165
+ response_packet = response_packet.with_hop(
166
+ make_response_hop(
167
+ packet=response_packet,
168
+ node=normalized_node_name,
169
+ action=response_packet.header.action,
170
+ status=payload_status,
171
+ )
172
+ )
173
+
174
+ resolved_signing_key = _resolve_signing_material(
175
+ signing_key=signing_key,
176
+ signing_private_key=signing_private_key,
177
+ signing_algorithm=signing_algorithm,
178
+ )
179
+ if resolved_signing_key is not None:
180
+ if signing_key_id is None or signing_algorithm is None:
181
+ raise ValueError(
182
+ "signing_key_id and signing_algorithm are required when signing responses"
183
+ )
184
+ response_packet = sign_transport_packet(
185
+ response_packet,
186
+ key=resolved_signing_key,
187
+ key_id=signing_key_id,
188
+ algorithm=signing_algorithm,
189
+ )
190
+
191
+ return response_packet
192
+
193
+
194
+ def create_error_transport_packet(
195
+ packet: TransportPacket,
196
+ error: Exception,
197
+ *,
198
+ node_name: str,
199
+ signing_key: bytes | str | None = None,
200
+ signing_private_key: str | None = None,
201
+ signing_key_id: str | None = None,
202
+ signing_algorithm: str | None = None,
203
+ expose_internal_errors: bool = False,
204
+ ) -> TransportPacket:
205
+ normalized_node_name = node_name.strip().lower()
206
+ client_message = str(error) if expose_internal_errors else error.__class__.__name__
207
+
208
+ response_packet = packet.derive(
209
+ packet_type="failure",
210
+ source_node=normalized_node_name,
211
+ destination_node=packet.address.reply_to,
212
+ reply_to=normalized_node_name,
213
+ payload={
214
+ "status": "failed",
215
+ "error": error.__class__.__name__,
216
+ "message": client_message,
217
+ "packet_id": str(packet.header.packet_id),
218
+ },
219
+ )
220
+
221
+ response_packet = response_packet.with_hop(
222
+ make_response_hop(
223
+ packet=response_packet,
224
+ node=normalized_node_name,
225
+ action=response_packet.header.action,
226
+ status="failed",
227
+ error_code=error.__class__.__name__,
228
+ error_message=str(error) if expose_internal_errors else client_message,
229
+ )
230
+ )
231
+
232
+ resolved_signing_key = _resolve_signing_material(
233
+ signing_key=signing_key,
234
+ signing_private_key=signing_private_key,
235
+ signing_algorithm=signing_algorithm,
236
+ )
237
+ if resolved_signing_key is not None:
238
+ if signing_key_id is None or signing_algorithm is None:
239
+ raise ValueError(
240
+ "signing_key_id and signing_algorithm are required when signing error responses"
241
+ )
242
+ response_packet = sign_transport_packet(
243
+ response_packet,
244
+ key=resolved_signing_key,
245
+ key_id=signing_key_id,
246
+ algorithm=signing_algorithm,
247
+ )
248
+
249
+ return response_packet
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ _HANDLER_REGISTRY: dict[str, Callable[..., Any]] = {}
7
+
8
+
9
+ def register_handler(action: str, fn: Callable[..., Any] | None = None) -> Callable[..., Any]:
10
+ """
11
+ Register a runtime handler for a transport action.
12
+ """
13
+ normalized_action = action.strip().lower()
14
+ if not normalized_action:
15
+ raise ValueError("action must not be empty")
16
+
17
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
18
+ _HANDLER_REGISTRY[normalized_action] = func
19
+ return func
20
+
21
+ if fn is not None:
22
+ return decorator(fn)
23
+ return decorator
24
+
25
+
26
+ def get_handler(action: str) -> Callable[..., Any] | None:
27
+ """
28
+ Resolve a handler by exact action, falling back to '*' if present.
29
+ """
30
+ normalized_action = action.strip().lower()
31
+ return _HANDLER_REGISTRY.get(normalized_action) or _HANDLER_REGISTRY.get("*")
32
+
33
+
34
+ def clear_handlers() -> None:
35
+ """
36
+ Clear all registered handlers.
37
+ """
38
+ _HANDLER_REGISTRY.clear()
39
+
40
+
41
+ def registered_actions() -> tuple[str, ...]:
42
+ """
43
+ Return the registered action names in sorted order.
44
+ """
45
+ return tuple(sorted(_HANDLER_REGISTRY.keys()))
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from constellation_node_sdk.transport.packet import TransportPacket
4
+
5
+
6
+ def assert_gate_source(packet: TransportPacket, *, gate_node_name: str = "gate") -> None:
7
+ expected = gate_node_name.strip().lower()
8
+ actual = packet.address.source_node.strip().lower()
9
+ if actual != expected:
10
+ raise ValueError(f"inbound packets must originate from {expected!r}, got {actual!r}")
11
+
12
+
13
+ def assert_packet_targets_local_node(packet: TransportPacket, *, local_node: str) -> None:
14
+ expected = local_node.strip().lower()
15
+ actual = packet.address.destination_node.strip().lower()
16
+ if actual != expected:
17
+ raise ValueError(
18
+ f"inbound packet destination_node {actual!r} does not match local node {expected!r}"
19
+ )
20
+
21
+
22
+ def assert_gate_mediated_provenance(
23
+ packet: TransportPacket,
24
+ *,
25
+ allow_route_kinds: tuple[str, ...],
26
+ require_route_kind: bool = True,
27
+ ) -> None:
28
+ if not packet.provenance.resolved_by_gate:
29
+ raise ValueError("inbound packet provenance.resolved_by_gate must be true")
30
+
31
+ if packet.provenance.origin_kind not in {"client", "node", "gate"}:
32
+ raise ValueError("invalid inbound provenance.origin_kind")
33
+
34
+ route_kind = packet.provenance.route_kind
35
+ if route_kind is None:
36
+ if require_route_kind:
37
+ raise ValueError("inbound packet provenance.route_kind is required")
38
+ return
39
+
40
+ normalized_route_kind = route_kind.strip().lower()
41
+ normalized_allowed = tuple(value.strip().lower() for value in allow_route_kinds)
42
+ if normalized_route_kind not in normalized_allowed:
43
+ raise ValueError(
44
+ f"inbound packet provenance.route_kind must be one of "
45
+ f"{normalized_allowed}, got {normalized_route_kind!r}"
46
+ )
47
+
48
+
49
+ def validate_execute_ingress_packet(
50
+ packet: TransportPacket,
51
+ *,
52
+ local_node: str,
53
+ gate_node_name: str = "gate",
54
+ require_route_kind: bool = True,
55
+ ) -> None:
56
+ assert_gate_source(packet, gate_node_name=gate_node_name)
57
+ assert_packet_targets_local_node(packet, local_node=local_node)
58
+ assert_gate_mediated_provenance(
59
+ packet,
60
+ allow_route_kinds=("external_ingress",),
61
+ require_route_kind=require_route_kind,
62
+ )
63
+
64
+
65
+ def validate_relay_ingress_packet(
66
+ packet: TransportPacket,
67
+ *,
68
+ local_node: str,
69
+ gate_node_name: str = "gate",
70
+ require_route_kind: bool = True,
71
+ allowed_actions: tuple[str, ...] | None = None,
72
+ allowed_packet_types: tuple[str, ...] | None = None,
73
+ ) -> None:
74
+ assert_gate_source(packet, gate_node_name=gate_node_name)
75
+ assert_packet_targets_local_node(packet, local_node=local_node)
76
+ assert_gate_mediated_provenance(
77
+ packet,
78
+ allow_route_kinds=("gate_relay",),
79
+ require_route_kind=require_route_kind,
80
+ )
81
+
82
+ if allowed_actions and packet.header.action not in allowed_actions:
83
+ raise ValueError(f"relay action {packet.header.action!r} is not allowed")
84
+
85
+ if allowed_packet_types and packet.header.packet_type not in allowed_packet_types:
86
+ raise ValueError(f"relay packet_type {packet.header.packet_type!r} is not allowed")
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class LifecycleHook(ABC):
7
+ """
8
+ Minimal lifecycle interface for node startup and shutdown hooks.
9
+ """
10
+
11
+ @abstractmethod
12
+ async def startup(self) -> None:
13
+ """Run node-specific startup logic."""
14
+
15
+ @abstractmethod
16
+ async def shutdown(self) -> None:
17
+ """Run node-specific shutdown logic."""
18
+
19
+
20
+ class NoOpLifecycle(LifecycleHook):
21
+ """
22
+ Default lifecycle hook when a node has no custom startup or shutdown behavior.
23
+ """
24
+
25
+ async def startup(self) -> None:
26
+ return None
27
+
28
+ async def shutdown(self) -> None:
29
+ return None
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+
6
+ from prometheus_client import (
7
+ CONTENT_TYPE_LATEST,
8
+ CollectorRegistry,
9
+ Counter,
10
+ Gauge,
11
+ generate_latest,
12
+ )
13
+ from pythonjsonlogger import jsonlogger
14
+ from starlette.responses import Response
15
+
16
+ from .config import NodeRuntimeConfig
17
+
18
+ REGISTRY = CollectorRegistry(auto_describe=True)
19
+ REQUESTS_TOTAL = Counter(
20
+ "constellation_node_requests_total",
21
+ "Total execute requests",
22
+ ["service", "action", "status"],
23
+ registry=REGISTRY,
24
+ )
25
+ READY_GAUGE = Gauge(
26
+ "constellation_node_ready",
27
+ "Service readiness",
28
+ ["service"],
29
+ registry=REGISTRY,
30
+ )
31
+
32
+
33
+ def configure_logging(config: NodeRuntimeConfig) -> None:
34
+ """
35
+ Configure root logging for the node runtime.
36
+ """
37
+ root = logging.getLogger()
38
+ root.handlers.clear()
39
+ root.setLevel(logging.INFO)
40
+
41
+ handler = logging.StreamHandler(sys.stdout)
42
+ handler.setFormatter(
43
+ jsonlogger.JsonFormatter( # type: ignore[attr-defined]
44
+ "%(asctime)s %(levelname)s %(name)s %(message)s"
45
+ )
46
+ )
47
+ root.addHandler(handler)
48
+
49
+
50
+ def set_readiness(*, config: NodeRuntimeConfig, ready: bool) -> None:
51
+ """
52
+ Update readiness gauge for the current service.
53
+ """
54
+ READY_GAUGE.labels(service=config.service_name).set(1 if ready else 0)
55
+
56
+
57
+ def record_request(*, config: NodeRuntimeConfig, action: str, status: str) -> None:
58
+ """
59
+ Increment request counter for an executed action.
60
+ """
61
+ REQUESTS_TOTAL.labels(
62
+ service=config.service_name,
63
+ action=action.strip().lower(),
64
+ status=status.strip().lower(),
65
+ ).inc()
66
+
67
+
68
+ def metrics_response() -> Response:
69
+ """
70
+ Return the Prometheus metrics payload.
71
+ """
72
+ return Response(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import NodeRuntimeConfig
4
+
5
+
6
+ class PreflightFailure(RuntimeError):
7
+ """Raised when runtime configuration is not safe to start."""
8
+
9
+
10
+ def run_preflight(config: NodeRuntimeConfig) -> None:
11
+ """
12
+ Validate runtime configuration relationships before starting a node runtime.
13
+ """
14
+ _validate_security_profile(config)
15
+ _validate_action_configuration(config)
16
+ _validate_attachment_configuration(config)
17
+ _validate_gate_configuration(config)
18
+ _validate_gate_ingress_authentication(config)
19
+
20
+
21
+ def _validate_security_profile(config: NodeRuntimeConfig) -> None:
22
+ if config.dev_mode:
23
+ return
24
+
25
+ if not config.require_signature:
26
+ return
27
+
28
+ if config.signing_algorithm == "hmac-sha256":
29
+ if not config.signing_key:
30
+ raise PreflightFailure("hmac-sha256 requires signing_key when signatures are required")
31
+ return
32
+
33
+ if config.signing_algorithm == "ed25519":
34
+ if not config.signing_private_key:
35
+ raise PreflightFailure(
36
+ "ed25519 requires signing_private_key when signatures are required"
37
+ )
38
+ return
39
+
40
+ raise PreflightFailure(f"unsupported signing algorithm: {config.signing_algorithm}")
41
+
42
+
43
+ def _validate_action_configuration(config: NodeRuntimeConfig) -> None:
44
+ allowed_actions = set(config.allowed_actions)
45
+ execute_allowed_actions = set(config.execute_allowed_actions)
46
+ known_actions = allowed_actions | execute_allowed_actions
47
+ required_idempotency = set(config.require_idempotency_for_actions)
48
+
49
+ if required_idempotency and not known_actions:
50
+ raise PreflightFailure(
51
+ "require_idempotency_for_actions is configured but allowed_actions and"
52
+ " execute_allowed_actions are both empty"
53
+ )
54
+
55
+ if not required_idempotency.issubset(known_actions):
56
+ invalid = sorted(required_idempotency - known_actions)
57
+ joined = ", ".join(invalid)
58
+ raise PreflightFailure(
59
+ "require_idempotency_for_actions contains actions not present"
60
+ f" in allowed_actions or execute_allowed_actions: {joined}"
61
+ )
62
+
63
+ if "response" in allowed_actions or "failure" in allowed_actions:
64
+ raise PreflightFailure(
65
+ "allowed_actions must list executable inbound actions only, not response packet types"
66
+ )
67
+
68
+
69
+ def _validate_attachment_configuration(config: NodeRuntimeConfig) -> None:
70
+ if config.max_attachment_size_bytes > config.max_packet_bytes:
71
+ raise PreflightFailure("max_attachment_size_bytes must not exceed max_packet_bytes")
72
+
73
+ if config.max_attachments > 0 and not config.attachment_allowed_schemes:
74
+ raise PreflightFailure(
75
+ "attachment_allowed_schemes must be configured when attachments are enabled"
76
+ )
77
+
78
+
79
+ def _validate_gate_configuration(config: NodeRuntimeConfig) -> None:
80
+ if config.gate_url is None:
81
+ return
82
+ if not (config.gate_url.startswith("http://") or config.gate_url.startswith("https://")):
83
+ raise PreflightFailure("gate_url must start with http:// or https://")
84
+
85
+
86
+ def _validate_gate_ingress_authentication(config: NodeRuntimeConfig) -> None:
87
+ """
88
+ Gate-only ingress trust (provenance.resolved_by_gate, address.source_node,
89
+ provenance.route_kind) is caller-supplied and carries no authentication by
90
+ itself. If enforce_gate_only_ingress is relied upon without also requiring
91
+ a verified transport signature, any direct client can forge these claims
92
+ and bypass Gate-mediated routing entirely. Fail closed outside dev_mode.
93
+ """
94
+ if config.dev_mode or not config.enforce_gate_only_ingress:
95
+ return
96
+
97
+ execute_require_signature = (
98
+ config.execute_require_signature
99
+ if config.execute_require_signature is not None
100
+ else config.require_signature
101
+ )
102
+ if not execute_require_signature:
103
+ raise PreflightFailure(
104
+ "enforce_gate_only_ingress=true requires a verified signature on /v1/execute "
105
+ "(set require_signature=true or execute_require_signature=true); "
106
+ "unsigned Gate-only ingress trusts unauthenticated caller-supplied provenance"
107
+ )
108
+
109
+ if not config.enable_relay_route:
110
+ return
111
+
112
+ relay_require_signature = (
113
+ config.relay_require_signature
114
+ if config.relay_require_signature is not None
115
+ else config.require_signature
116
+ )
117
+ if not relay_require_signature:
118
+ raise PreflightFailure(
119
+ "enforce_gate_only_ingress=true requires a verified signature on /v1/relay "
120
+ "(set require_signature=true or relay_require_signature=true); "
121
+ "unsigned Gate-only ingress trusts unauthenticated caller-supplied provenance"
122
+ )
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from constellation_node_sdk.security.delegation import compute_delegation_proof
4
+ from constellation_node_sdk.security.errors import (
5
+ SecurityError,
6
+ TransportAuthenticationError,
7
+ TransportAuthorizationError,
8
+ TransportExpiredError,
9
+ TransportIntegrityError,
10
+ TransportNotYetValidError,
11
+ TransportValidationError,
12
+ )
13
+ from constellation_node_sdk.security.signing import recompute_transport_core, sign_transport_packet
14
+ from constellation_node_sdk.security.validation import (
15
+ transport_packet_size_bytes,
16
+ validate_derived_transport_packet,
17
+ validate_transport_packet,
18
+ )
19
+ from constellation_node_sdk.security.verification import verify_transport_packet_signature
20
+
21
+ __all__ = [
22
+ "SecurityError",
23
+ "TransportAuthenticationError",
24
+ "TransportAuthorizationError",
25
+ "TransportExpiredError",
26
+ "TransportIntegrityError",
27
+ "TransportNotYetValidError",
28
+ "TransportValidationError",
29
+ "compute_delegation_proof",
30
+ "recompute_transport_core",
31
+ "sign_transport_packet",
32
+ "transport_packet_size_bytes",
33
+ "validate_derived_transport_packet",
34
+ "validate_transport_packet",
35
+ "verify_transport_packet_signature",
36
+ ]
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from constellation_node_sdk.transport.hashing import canonical_json
10
+ from constellation_node_sdk.transport.packet import TransportPacket
11
+
12
+
13
+ def _coerce_bytes(value: str | bytes) -> bytes:
14
+ if isinstance(value, bytes):
15
+ return value
16
+ normalized = value.strip()
17
+ try:
18
+ return base64.b64decode(normalized, validate=True)
19
+ except Exception:
20
+ return normalized.encode("utf-8")
21
+
22
+
23
+ def compute_delegation_proof(
24
+ *,
25
+ packet: TransportPacket,
26
+ delegator: str,
27
+ delegatee: str,
28
+ scope: tuple[str, ...],
29
+ granted_at: datetime,
30
+ key: bytes | str,
31
+ expires_at: datetime | None = None,
32
+ constraints: dict[str, Any] | None = None,
33
+ ) -> str:
34
+ """
35
+ Compute an HMAC proof for a delegation grant.
36
+
37
+ Delegation proofs are bound to:
38
+ - packet_id
39
+ - transport_hash
40
+ - delegator/delegatee
41
+ - scope
42
+ - grant time
43
+ - optional expiry/constraints
44
+ """
45
+ raw_key = _coerce_bytes(key)
46
+ normalized_scope = tuple(item.strip().lower() for item in scope if item.strip())
47
+ message = {
48
+ "packet_id": str(packet.header.packet_id),
49
+ "transport_hash": packet.security.transport_hash,
50
+ "delegator": delegator.strip().lower(),
51
+ "delegatee": delegatee.strip().lower(),
52
+ "scope": list(normalized_scope),
53
+ "granted_at": granted_at.astimezone(UTC).isoformat(),
54
+ "expires_at": expires_at.astimezone(UTC).isoformat() if expires_at else None,
55
+ "constraints": constraints or {},
56
+ }
57
+ return hmac.new(raw_key, canonical_json(message), hashlib.sha256).hexdigest()