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.
- constellation_node_sdk/__init__.py +123 -0
- constellation_node_sdk/gate/__init__.py +47 -0
- constellation_node_sdk/gate/client.py +108 -0
- constellation_node_sdk/gate/config.py +153 -0
- constellation_node_sdk/gate/errors.py +17 -0
- constellation_node_sdk/gate/policy.py +70 -0
- constellation_node_sdk/gate/registration.py +144 -0
- constellation_node_sdk/orchestrator/__init__.py +3 -0
- constellation_node_sdk/orchestrator/base.py +57 -0
- constellation_node_sdk/orchestrator/merge.py +47 -0
- constellation_node_sdk/orchestrator/packet_builder.py +49 -0
- constellation_node_sdk/orchestrator/retry.py +61 -0
- constellation_node_sdk/orchestrator/state.py +78 -0
- constellation_node_sdk/orchestrator/step_executor.py +87 -0
- constellation_node_sdk/py.typed +0 -0
- constellation_node_sdk/runtime/__init__.py +52 -0
- constellation_node_sdk/runtime/app.py +274 -0
- constellation_node_sdk/runtime/config.py +307 -0
- constellation_node_sdk/runtime/errors.py +112 -0
- constellation_node_sdk/runtime/execution.py +249 -0
- constellation_node_sdk/runtime/handlers.py +45 -0
- constellation_node_sdk/runtime/inbound_policy.py +86 -0
- constellation_node_sdk/runtime/lifecycle.py +29 -0
- constellation_node_sdk/runtime/observability.py +72 -0
- constellation_node_sdk/runtime/preflight.py +122 -0
- constellation_node_sdk/security/__init__.py +36 -0
- constellation_node_sdk/security/delegation.py +57 -0
- constellation_node_sdk/security/errors.py +25 -0
- constellation_node_sdk/security/signing.py +121 -0
- constellation_node_sdk/security/validation.py +230 -0
- constellation_node_sdk/security/verification.py +107 -0
- constellation_node_sdk/transport/__init__.py +88 -0
- constellation_node_sdk/transport/codec.py +21 -0
- constellation_node_sdk/transport/errors.py +41 -0
- constellation_node_sdk/transport/hashing.py +73 -0
- constellation_node_sdk/transport/hop_trace.py +378 -0
- constellation_node_sdk/transport/lineage.py +28 -0
- constellation_node_sdk/transport/models.py +447 -0
- constellation_node_sdk/transport/packet.py +331 -0
- constellation_node_sdk/transport/provenance.py +53 -0
- constellation_node_sdk/transport/tenant.py +76 -0
- constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
- constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
- constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
- 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")
|