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,73 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from pydantic import BaseModel
11
+
12
+
13
+ def _canonicalize(value: Any) -> Any:
14
+ """Recursively convert supported values into stable JSON-serializable forms."""
15
+ if isinstance(value, BaseModel):
16
+ return _canonicalize(value.model_dump(mode="python", exclude_none=False))
17
+ if isinstance(value, dict):
18
+ return {
19
+ str(k): _canonicalize(v)
20
+ for k, v in sorted(value.items(), key=lambda item: str(item[0]))
21
+ }
22
+ if isinstance(value, (list, tuple)):
23
+ return [_canonicalize(v) for v in value]
24
+ if isinstance(value, datetime):
25
+ if value.tzinfo is None:
26
+ value = value.replace(tzinfo=datetime.now().astimezone().tzinfo)
27
+ return value.astimezone().isoformat().replace("+00:00", "Z")
28
+ if isinstance(value, UUID):
29
+ return str(value)
30
+ if isinstance(value, bytes):
31
+ return base64.b64encode(value).decode("ascii")
32
+ return value
33
+
34
+
35
+ def canonical_json(value: Any) -> bytes:
36
+ """Produce stable canonical JSON bytes for hashing."""
37
+ normalized = _canonicalize(value)
38
+ return json.dumps(
39
+ normalized,
40
+ sort_keys=True,
41
+ separators=(",", ":"),
42
+ ensure_ascii=False,
43
+ allow_nan=False,
44
+ ).encode("utf-8")
45
+
46
+
47
+ def compute_payload_hash(payload: dict[str, Any]) -> str:
48
+ """Compute the canonical SHA-256 hash of a transport payload."""
49
+ return hashlib.sha256(canonical_json(payload)).hexdigest()
50
+
51
+
52
+ def compute_transport_hash(packet: Any) -> str:
53
+ """
54
+ Compute the canonical SHA-256 hash of a transport packet.
55
+
56
+ The transport hash intentionally excludes mutable hop trace data and
57
+ excludes signature fields. It covers the stable transport contract:
58
+ header, address, tenant, payload, governance, delegation_chain,
59
+ lineage, attachments, provenance, and payload_hash.
60
+ """
61
+ envelope_payload = {
62
+ "header": packet.header,
63
+ "address": packet.address,
64
+ "tenant": packet.tenant,
65
+ "payload": packet.payload,
66
+ "governance": packet.governance,
67
+ "delegation_chain": packet.delegation_chain,
68
+ "lineage": packet.lineage,
69
+ "attachments": packet.attachments,
70
+ "provenance": packet.provenance,
71
+ "payload_hash": packet.security.payload_hash,
72
+ }
73
+ return hashlib.sha256(canonical_json(envelope_payload)).hexdigest()
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ from collections.abc import Callable, Mapping
7
+ from datetime import datetime
8
+ from uuid import uuid4
9
+
10
+ from cryptography.exceptions import InvalidSignature
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
12
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key
13
+
14
+ from constellation_node_sdk.transport.errors import (
15
+ TransportAuthenticationError,
16
+ TransportIntegrityError,
17
+ TransportValidationError,
18
+ )
19
+ from constellation_node_sdk.transport.hashing import canonical_json
20
+ from constellation_node_sdk.transport.models import TransportHop, ensure_utc, utc_now
21
+ from constellation_node_sdk.transport.packet import TransportPacket
22
+
23
+
24
+ def _coerce_bytes(value: str | bytes) -> bytes:
25
+ if isinstance(value, bytes):
26
+ return value
27
+ normalized = value.strip()
28
+ if normalized.startswith("-----BEGIN"):
29
+ return normalized.encode("utf-8")
30
+ try:
31
+ return base64.b64decode(normalized, validate=True)
32
+ except Exception:
33
+ return normalized.encode("utf-8")
34
+
35
+
36
+ def _resolve_key(
37
+ key_resolver: Callable[[str | None], str | bytes | None]
38
+ | Mapping[str, str | bytes]
39
+ | str
40
+ | bytes
41
+ | None,
42
+ key_id: str | None,
43
+ ) -> bytes | None:
44
+ if key_resolver is None:
45
+ return None
46
+ if isinstance(key_resolver, (bytes, str)):
47
+ return _coerce_bytes(key_resolver)
48
+ if callable(key_resolver):
49
+ value = key_resolver(key_id)
50
+ else:
51
+ if key_id is None:
52
+ return None
53
+ value = key_resolver.get(key_id)
54
+ if value is None:
55
+ return None
56
+ return _coerce_bytes(value)
57
+
58
+
59
+ def _load_ed25519_private_key(raw: bytes) -> Ed25519PrivateKey:
60
+ if raw.startswith(b"-----BEGIN"):
61
+ loaded = load_pem_private_key(raw, password=None)
62
+ if not isinstance(loaded, Ed25519PrivateKey):
63
+ raise TransportAuthenticationError("PEM private key is not Ed25519")
64
+ return loaded
65
+ if len(raw) == 32:
66
+ return Ed25519PrivateKey.from_private_bytes(raw)
67
+ raise TransportAuthenticationError("unsupported ed25519 private key format")
68
+
69
+
70
+ def _load_ed25519_public_key(raw: bytes) -> Ed25519PublicKey:
71
+ if raw.startswith(b"-----BEGIN"):
72
+ loaded = load_pem_public_key(raw)
73
+ if not isinstance(loaded, Ed25519PublicKey):
74
+ raise TransportAuthenticationError("PEM public key is not Ed25519")
75
+ return loaded
76
+ if len(raw) == 32:
77
+ return Ed25519PublicKey.from_public_bytes(raw)
78
+ raise TransportAuthenticationError("unsupported ed25519 public key format")
79
+
80
+
81
+ def last_hop_hash(packet: TransportPacket) -> str | None:
82
+ if not packet.hop_trace:
83
+ return None
84
+ return packet.hop_trace[-1].hop_hash
85
+
86
+
87
+ def compute_hop_hash(*, transport_hash: str, hop: TransportHop) -> str:
88
+ payload = {
89
+ "transport_hash": transport_hash,
90
+ "hop_id": str(hop.hop_id),
91
+ "packet_id": str(hop.packet_id),
92
+ "node": hop.node,
93
+ "action": hop.action,
94
+ "direction": hop.direction,
95
+ "status": hop.status,
96
+ "timestamp": hop.timestamp,
97
+ "attempt": hop.attempt,
98
+ "target_node": hop.target_node,
99
+ "duration_ms": hop.duration_ms,
100
+ "queue_ms": hop.queue_ms,
101
+ "network_ms": hop.network_ms,
102
+ "error_code": hop.error_code,
103
+ "error_message": hop.error_message,
104
+ "previous_hop_hash": hop.previous_hop_hash,
105
+ }
106
+ return hashlib.sha256(canonical_json(payload)).hexdigest()
107
+
108
+
109
+ def sign_hop(
110
+ hop: TransportHop,
111
+ *,
112
+ key: bytes | str,
113
+ key_id: str,
114
+ algorithm: str,
115
+ ) -> TransportHop:
116
+ normalized_algorithm = algorithm.strip().lower()
117
+ raw_key = _coerce_bytes(key)
118
+
119
+ if normalized_algorithm == "hmac-sha256":
120
+ signature = hmac.new(raw_key, hop.hop_hash.encode("utf-8"), hashlib.sha256).hexdigest()
121
+ elif normalized_algorithm == "ed25519":
122
+ private_key = _load_ed25519_private_key(raw_key)
123
+ signature = private_key.sign(hop.hop_hash.encode("utf-8")).hex()
124
+ else:
125
+ raise TransportAuthenticationError(
126
+ f"unsupported hop signature algorithm: {normalized_algorithm}"
127
+ )
128
+
129
+ return hop.model_copy(
130
+ update={
131
+ "hop_signature": signature,
132
+ "hop_signature_algorithm": normalized_algorithm,
133
+ "hop_signing_key_id": key_id.strip(),
134
+ }
135
+ )
136
+
137
+
138
+ def verify_hop_signature(
139
+ hop: TransportHop,
140
+ *,
141
+ key_resolver: Callable[[str | None], str | bytes | None]
142
+ | Mapping[str, str | bytes]
143
+ | str
144
+ | bytes
145
+ | None,
146
+ ) -> bool:
147
+ if hop.hop_signature is None or hop.hop_signature_algorithm is None:
148
+ return False
149
+
150
+ key = _resolve_key(key_resolver, hop.hop_signing_key_id)
151
+ if key is None:
152
+ raise TransportAuthenticationError(
153
+ "no verifying key available for hop signature verification"
154
+ )
155
+
156
+ if hop.hop_signature_algorithm == "hmac-sha256":
157
+ expected = hmac.new(key, hop.hop_hash.encode("utf-8"), hashlib.sha256).hexdigest()
158
+ return hmac.compare_digest(hop.hop_signature, expected)
159
+
160
+ if hop.hop_signature_algorithm == "ed25519":
161
+ public_key = _load_ed25519_public_key(key)
162
+ try:
163
+ public_key.verify(bytes.fromhex(hop.hop_signature), hop.hop_hash.encode("utf-8"))
164
+ return True
165
+ except (ValueError, InvalidSignature) as exc:
166
+ raise TransportAuthenticationError("invalid ed25519 hop signature") from exc
167
+
168
+ raise TransportAuthenticationError(
169
+ f"unsupported hop signature algorithm: {hop.hop_signature_algorithm}"
170
+ )
171
+
172
+
173
+ def _finalize_hop(
174
+ *,
175
+ packet: TransportPacket,
176
+ node: str,
177
+ action: str,
178
+ direction: str,
179
+ status: str,
180
+ attempt: int | None = None,
181
+ target_node: str | None = None,
182
+ duration_ms: int | None = None,
183
+ queue_ms: int | None = None,
184
+ network_ms: int | None = None,
185
+ error_code: str | None = None,
186
+ error_message: str | None = None,
187
+ timestamp: datetime | None = None,
188
+ key: bytes | str | None = None,
189
+ key_id: str | None = None,
190
+ algorithm: str | None = None,
191
+ ) -> TransportHop:
192
+ provisional = TransportHop(
193
+ hop_id=uuid4(),
194
+ packet_id=packet.header.packet_id,
195
+ node=node.strip().lower(),
196
+ action=action.strip().lower(),
197
+ direction=direction.strip().lower(),
198
+ status=status.strip().lower(),
199
+ timestamp=ensure_utc(timestamp) or utc_now(),
200
+ attempt=attempt,
201
+ target_node=None if target_node is None else target_node.strip().lower(),
202
+ duration_ms=duration_ms,
203
+ queue_ms=queue_ms,
204
+ network_ms=network_ms,
205
+ error_code=error_code,
206
+ error_message=error_message,
207
+ previous_hop_hash=last_hop_hash(packet),
208
+ hop_hash="0" * 64,
209
+ hop_signature=None,
210
+ hop_signature_algorithm=None,
211
+ hop_signing_key_id=None,
212
+ )
213
+ hop_hash = compute_hop_hash(transport_hash=packet.security.transport_hash, hop=provisional)
214
+ finalized = provisional.model_copy(update={"hop_hash": hop_hash})
215
+
216
+ if key is not None:
217
+ if key_id is None or algorithm is None:
218
+ raise TransportValidationError("key_id and algorithm are required when signing a hop")
219
+ finalized = sign_hop(finalized, key=key, key_id=key_id, algorithm=algorithm)
220
+
221
+ return TransportHop.model_validate(finalized)
222
+
223
+
224
+ def make_ingress_hop(
225
+ *,
226
+ packet: TransportPacket,
227
+ node: str,
228
+ action: str,
229
+ status: str = "validated",
230
+ attempt: int | None = None,
231
+ queue_ms: int | None = None,
232
+ key: bytes | str | None = None,
233
+ key_id: str | None = None,
234
+ algorithm: str | None = None,
235
+ ) -> TransportHop:
236
+ return _finalize_hop(
237
+ packet=packet,
238
+ node=node,
239
+ action=action,
240
+ direction="ingress",
241
+ status=status,
242
+ attempt=attempt,
243
+ queue_ms=queue_ms,
244
+ key=key,
245
+ key_id=key_id,
246
+ algorithm=algorithm,
247
+ )
248
+
249
+
250
+ def make_dispatch_hop(
251
+ *,
252
+ packet: TransportPacket,
253
+ node: str,
254
+ action: str,
255
+ target_node: str,
256
+ status: str = "delegated",
257
+ attempt: int | None = None,
258
+ network_ms: int | None = None,
259
+ key: bytes | str | None = None,
260
+ key_id: str | None = None,
261
+ algorithm: str | None = None,
262
+ ) -> TransportHop:
263
+ return _finalize_hop(
264
+ packet=packet,
265
+ node=node,
266
+ action=action,
267
+ direction="dispatch",
268
+ status=status,
269
+ attempt=attempt,
270
+ target_node=target_node,
271
+ network_ms=network_ms,
272
+ key=key,
273
+ key_id=key_id,
274
+ algorithm=algorithm,
275
+ )
276
+
277
+
278
+ def make_execution_hop(
279
+ *,
280
+ packet: TransportPacket,
281
+ node: str,
282
+ action: str,
283
+ status: str = "processing",
284
+ attempt: int | None = None,
285
+ duration_ms: int | None = None,
286
+ error_code: str | None = None,
287
+ error_message: str | None = None,
288
+ key: bytes | str | None = None,
289
+ key_id: str | None = None,
290
+ algorithm: str | None = None,
291
+ ) -> TransportHop:
292
+ return _finalize_hop(
293
+ packet=packet,
294
+ node=node,
295
+ action=action,
296
+ direction="execution",
297
+ status=status,
298
+ attempt=attempt,
299
+ duration_ms=duration_ms,
300
+ error_code=error_code,
301
+ error_message=error_message,
302
+ key=key,
303
+ key_id=key_id,
304
+ algorithm=algorithm,
305
+ )
306
+
307
+
308
+ def make_response_hop(
309
+ *,
310
+ packet: TransportPacket,
311
+ node: str,
312
+ action: str,
313
+ status: str = "completed",
314
+ attempt: int | None = None,
315
+ duration_ms: int | None = None,
316
+ error_code: str | None = None,
317
+ error_message: str | None = None,
318
+ key: bytes | str | None = None,
319
+ key_id: str | None = None,
320
+ algorithm: str | None = None,
321
+ ) -> TransportHop:
322
+ return _finalize_hop(
323
+ packet=packet,
324
+ node=node,
325
+ action=action,
326
+ direction="response",
327
+ status=status,
328
+ attempt=attempt,
329
+ duration_ms=duration_ms,
330
+ error_code=error_code,
331
+ error_message=error_message,
332
+ key=key,
333
+ key_id=key_id,
334
+ algorithm=algorithm,
335
+ )
336
+
337
+
338
+ def validate_hop_trace(
339
+ packet: TransportPacket,
340
+ *,
341
+ verify_hop_signatures: bool = False,
342
+ hop_key_resolver: Callable[[str | None], str | bytes | None]
343
+ | Mapping[str, str | bytes]
344
+ | str
345
+ | bytes
346
+ | None = None,
347
+ require_monotonic_timestamps: bool = True,
348
+ ) -> None:
349
+ previous_hash: str | None = None
350
+ previous_timestamp: datetime | None = None
351
+
352
+ for index, hop in enumerate(packet.hop_trace):
353
+ if hop.packet_id != packet.header.packet_id:
354
+ raise TransportValidationError("hop packet_id does not match packet header packet_id")
355
+
356
+ if index == 0:
357
+ if hop.previous_hop_hash is not None:
358
+ raise TransportIntegrityError("first hop must set previous_hop_hash to null")
359
+ elif hop.previous_hop_hash != previous_hash:
360
+ raise TransportIntegrityError("hop chain continuity violation detected")
361
+
362
+ recomputed = compute_hop_hash(transport_hash=packet.security.transport_hash, hop=hop)
363
+ if hop.hop_hash != recomputed:
364
+ raise TransportIntegrityError("hop_hash does not match recomputed hop hash")
365
+
366
+ if (
367
+ require_monotonic_timestamps
368
+ and previous_timestamp is not None
369
+ and hop.timestamp < previous_timestamp
370
+ ):
371
+ raise TransportValidationError("hop timestamps must be non-decreasing")
372
+
373
+ if verify_hop_signatures and hop.hop_signature is not None:
374
+ if not verify_hop_signature(hop, key_resolver=hop_key_resolver):
375
+ raise TransportAuthenticationError("invalid hop signature")
376
+
377
+ previous_hash = hop.hop_hash
378
+ previous_timestamp = hop.timestamp
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from constellation_node_sdk.transport.errors import TransportValidationError
4
+ from constellation_node_sdk.transport.models import TransportLineage
5
+ from constellation_node_sdk.transport.packet import TransportPacket
6
+
7
+
8
+ def derive_lineage(parent: TransportPacket) -> TransportLineage:
9
+ """
10
+ Derive lineage for a semantic child packet.
11
+ """
12
+ return TransportLineage(
13
+ parent_id=parent.header.packet_id,
14
+ root_id=parent.lineage.root_id,
15
+ generation=parent.lineage.generation + 1,
16
+ )
17
+
18
+
19
+ def validate_parent_child_lineage(parent: TransportPacket, child: TransportPacket) -> None:
20
+ """
21
+ Validate that a child packet was correctly derived from a parent packet.
22
+ """
23
+ if child.lineage.parent_id != parent.header.packet_id:
24
+ raise TransportValidationError("child parent_id does not match parent packet_id")
25
+ if child.lineage.root_id != parent.lineage.root_id:
26
+ raise TransportValidationError("child root_id does not match parent root_id")
27
+ if child.lineage.generation != parent.lineage.generation + 1:
28
+ raise TransportValidationError("child generation must increment by 1")