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,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.transport.errors import (
|
|
4
|
+
TransportAuthenticationError,
|
|
5
|
+
TransportAuthorizationError,
|
|
6
|
+
TransportExpiredError,
|
|
7
|
+
TransportIntegrityError,
|
|
8
|
+
TransportNotYetValidError,
|
|
9
|
+
TransportValidationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecurityError(Exception):
|
|
14
|
+
"""Base exception for the SDK security layer."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"SecurityError",
|
|
19
|
+
"TransportAuthenticationError",
|
|
20
|
+
"TransportAuthorizationError",
|
|
21
|
+
"TransportExpiredError",
|
|
22
|
+
"TransportIntegrityError",
|
|
23
|
+
"TransportNotYetValidError",
|
|
24
|
+
"TransportValidationError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
|
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
9
|
+
|
|
10
|
+
from constellation_node_sdk.transport.hashing import compute_payload_hash, compute_transport_hash
|
|
11
|
+
from constellation_node_sdk.transport.models import TransportSecurity
|
|
12
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
13
|
+
|
|
14
|
+
from .errors import TransportAuthenticationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _coerce_bytes(value: str | bytes) -> bytes:
|
|
18
|
+
if isinstance(value, bytes):
|
|
19
|
+
return value
|
|
20
|
+
normalized = value.strip()
|
|
21
|
+
if normalized.startswith("-----BEGIN"):
|
|
22
|
+
return normalized.encode("utf-8")
|
|
23
|
+
try:
|
|
24
|
+
return base64.b64decode(normalized, validate=True)
|
|
25
|
+
except Exception:
|
|
26
|
+
return normalized.encode("utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_ed25519_private_key(raw: bytes) -> Ed25519PrivateKey:
|
|
30
|
+
if raw.startswith(b"-----BEGIN"):
|
|
31
|
+
loaded = load_pem_private_key(raw, password=None)
|
|
32
|
+
if not isinstance(loaded, Ed25519PrivateKey):
|
|
33
|
+
raise TransportAuthenticationError("PEM private key is not Ed25519")
|
|
34
|
+
return loaded
|
|
35
|
+
if len(raw) == 32:
|
|
36
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
37
|
+
raise TransportAuthenticationError("unsupported ed25519 private key format")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def recompute_transport_core(packet: TransportPacket) -> TransportPacket:
|
|
41
|
+
"""
|
|
42
|
+
Recompute payload_hash and transport_hash while preserving hop_trace.
|
|
43
|
+
|
|
44
|
+
hop_trace is intentionally excluded from transport_hash.
|
|
45
|
+
Any existing packet signature is cleared and must be re-applied by the caller.
|
|
46
|
+
"""
|
|
47
|
+
payload_hash = compute_payload_hash(packet.payload)
|
|
48
|
+
|
|
49
|
+
provisional = packet.model_copy(
|
|
50
|
+
update={
|
|
51
|
+
"security": packet.security.model_copy(
|
|
52
|
+
update={
|
|
53
|
+
"payload_hash": payload_hash,
|
|
54
|
+
"transport_hash": "0" * 64,
|
|
55
|
+
"signature": None,
|
|
56
|
+
"signature_algorithm": None,
|
|
57
|
+
"signing_key_id": None,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
transport_hash = compute_transport_hash(provisional)
|
|
64
|
+
finalized = provisional.model_copy(
|
|
65
|
+
update={
|
|
66
|
+
"security": provisional.security.model_copy(
|
|
67
|
+
update={
|
|
68
|
+
"transport_hash": transport_hash,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return TransportPacket.model_validate(finalized)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sign_transport_packet(
|
|
78
|
+
packet: TransportPacket,
|
|
79
|
+
*,
|
|
80
|
+
key: bytes | str,
|
|
81
|
+
key_id: str,
|
|
82
|
+
algorithm: str,
|
|
83
|
+
) -> TransportPacket:
|
|
84
|
+
"""
|
|
85
|
+
Sign a transport packet over its stable transport_hash.
|
|
86
|
+
|
|
87
|
+
The packet's hop_trace is preserved, but is not included in the signature domain.
|
|
88
|
+
"""
|
|
89
|
+
normalized_algorithm = algorithm.strip().lower()
|
|
90
|
+
raw_key = _coerce_bytes(key)
|
|
91
|
+
normalized = recompute_transport_core(packet)
|
|
92
|
+
|
|
93
|
+
if normalized_algorithm == "hmac-sha256":
|
|
94
|
+
signature = hmac.new(
|
|
95
|
+
raw_key,
|
|
96
|
+
normalized.security.transport_hash.encode("utf-8"),
|
|
97
|
+
hashlib.sha256,
|
|
98
|
+
).hexdigest()
|
|
99
|
+
elif normalized_algorithm == "ed25519":
|
|
100
|
+
private_key = _load_ed25519_private_key(raw_key)
|
|
101
|
+
signature = private_key.sign(normalized.security.transport_hash.encode("utf-8")).hex()
|
|
102
|
+
else:
|
|
103
|
+
raise TransportAuthenticationError(
|
|
104
|
+
f"unsupported signature algorithm: {normalized_algorithm}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
signed = normalized.model_copy(
|
|
108
|
+
update={
|
|
109
|
+
"security": TransportSecurity(
|
|
110
|
+
payload_hash=normalized.security.payload_hash,
|
|
111
|
+
transport_hash=normalized.security.transport_hash,
|
|
112
|
+
signature=signature,
|
|
113
|
+
signature_algorithm=normalized_algorithm,
|
|
114
|
+
signing_key_id=key_id.strip(),
|
|
115
|
+
classification=normalized.security.classification,
|
|
116
|
+
encryption_status=normalized.security.encryption_status,
|
|
117
|
+
pii_fields=normalized.security.pii_fields,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return TransportPacket.model_validate(signed)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from constellation_node_sdk.security.verification import verify_transport_packet_signature
|
|
10
|
+
from constellation_node_sdk.transport.errors import (
|
|
11
|
+
PacketSizeError,
|
|
12
|
+
SchemaVersionError,
|
|
13
|
+
TenantMutationError,
|
|
14
|
+
TransportAuthenticationError,
|
|
15
|
+
TransportAuthorizationError,
|
|
16
|
+
TransportExpiredError,
|
|
17
|
+
TransportIntegrityError,
|
|
18
|
+
TransportNotYetValidError,
|
|
19
|
+
TransportValidationError,
|
|
20
|
+
)
|
|
21
|
+
from constellation_node_sdk.transport.hop_trace import validate_hop_trace
|
|
22
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
23
|
+
from constellation_node_sdk.transport.tenant import assert_tenant_immutable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def transport_packet_size_bytes(packet: TransportPacket) -> int:
|
|
27
|
+
return len(
|
|
28
|
+
json.dumps(
|
|
29
|
+
packet.model_dump_json_dict(),
|
|
30
|
+
sort_keys=True,
|
|
31
|
+
separators=(",", ":"),
|
|
32
|
+
ensure_ascii=False,
|
|
33
|
+
).encode("utf-8")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_derived_transport_packet(parent: TransportPacket, child: TransportPacket) -> None:
|
|
38
|
+
try:
|
|
39
|
+
assert_tenant_immutable(parent.tenant, child.tenant)
|
|
40
|
+
except TenantMutationError:
|
|
41
|
+
raise
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise TenantMutationError(str(exc)) from exc
|
|
44
|
+
|
|
45
|
+
if child.lineage.parent_id != parent.header.packet_id:
|
|
46
|
+
raise TransportValidationError("derived packet parent_id mismatch")
|
|
47
|
+
if child.lineage.root_id != parent.lineage.root_id:
|
|
48
|
+
raise TransportValidationError("derived packet root_id mismatch")
|
|
49
|
+
if child.lineage.generation != parent.lineage.generation + 1:
|
|
50
|
+
raise TransportValidationError("derived packet generation must increment by 1")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_transport_packet(
|
|
54
|
+
packet: TransportPacket,
|
|
55
|
+
*,
|
|
56
|
+
key_resolver: Callable[[str | None], str | bytes | None]
|
|
57
|
+
| Mapping[str, str | bytes]
|
|
58
|
+
| str
|
|
59
|
+
| bytes
|
|
60
|
+
| None = None,
|
|
61
|
+
require_signature: bool = False,
|
|
62
|
+
max_packet_bytes: int = 262_144,
|
|
63
|
+
max_hop_depth: int = 64,
|
|
64
|
+
max_delegation_depth: int = 8,
|
|
65
|
+
max_attachments: int = 32,
|
|
66
|
+
max_attachment_size_bytes: int = 10_485_760,
|
|
67
|
+
allowed_attachment_schemes: tuple[str, ...] = (),
|
|
68
|
+
allow_private_attachment_hosts: bool = False,
|
|
69
|
+
allowed_clock_skew_seconds: int = 30,
|
|
70
|
+
local_node: str | None = None,
|
|
71
|
+
allowed_actions: tuple[str, ...] | None = None,
|
|
72
|
+
allowed_packet_types: tuple[str, ...] | None = None,
|
|
73
|
+
required_idempotency_actions: tuple[str, ...] | None = None,
|
|
74
|
+
replay_enabled: bool = True,
|
|
75
|
+
now: datetime | None = None,
|
|
76
|
+
dev_mode: bool = False,
|
|
77
|
+
verify_hop_signatures: bool = False,
|
|
78
|
+
hop_key_resolver: Callable[[str | None], str | bytes | None]
|
|
79
|
+
| Mapping[str, str | bytes]
|
|
80
|
+
| str
|
|
81
|
+
| bytes
|
|
82
|
+
| None = None,
|
|
83
|
+
require_monotonic_hop_timestamps: bool = True,
|
|
84
|
+
) -> None:
|
|
85
|
+
current_time = (now or datetime.now(UTC)).astimezone(UTC)
|
|
86
|
+
|
|
87
|
+
if packet.header.schema_version != "1.0":
|
|
88
|
+
raise SchemaVersionError(f"unsupported schema_version: {packet.header.schema_version}")
|
|
89
|
+
|
|
90
|
+
if allowed_packet_types is not None and packet.header.packet_type not in set(
|
|
91
|
+
allowed_packet_types
|
|
92
|
+
):
|
|
93
|
+
raise TransportAuthorizationError(f"packet_type not allowed: {packet.header.packet_type}")
|
|
94
|
+
|
|
95
|
+
if allowed_actions is not None and packet.header.action not in set(allowed_actions):
|
|
96
|
+
raise TransportAuthorizationError(f"action not allowed: {packet.header.action}")
|
|
97
|
+
|
|
98
|
+
if local_node is not None and packet.address.destination_node != local_node.strip().lower():
|
|
99
|
+
raise TransportAuthorizationError("packet destination does not match this node")
|
|
100
|
+
|
|
101
|
+
if packet.header.created_at > current_time + timedelta(seconds=allowed_clock_skew_seconds):
|
|
102
|
+
raise TransportValidationError("packet created_at exceeds allowed clock skew")
|
|
103
|
+
|
|
104
|
+
if transport_packet_size_bytes(packet) > max_packet_bytes:
|
|
105
|
+
raise PacketSizeError("packet exceeds maximum allowed size")
|
|
106
|
+
|
|
107
|
+
if len(packet.hop_trace) > max_hop_depth:
|
|
108
|
+
raise TransportValidationError("hop trace exceeds maximum depth")
|
|
109
|
+
|
|
110
|
+
if len(packet.delegation_chain) > max_delegation_depth:
|
|
111
|
+
raise TransportValidationError("delegation chain exceeds maximum depth")
|
|
112
|
+
|
|
113
|
+
if len(packet.attachments) > max_attachments:
|
|
114
|
+
raise TransportValidationError("attachment count exceeds maximum")
|
|
115
|
+
|
|
116
|
+
for attachment in packet.attachments:
|
|
117
|
+
if attachment.size_bytes > max_attachment_size_bytes:
|
|
118
|
+
raise TransportValidationError("attachment exceeds maximum allowed size")
|
|
119
|
+
_validate_attachment_uri(
|
|
120
|
+
attachment.uri,
|
|
121
|
+
allowed_schemes=allowed_attachment_schemes,
|
|
122
|
+
allow_private_hosts=allow_private_attachment_hosts,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not packet.security.payload_hash or not packet.security.transport_hash:
|
|
126
|
+
raise TransportIntegrityError("packet hashes are missing")
|
|
127
|
+
|
|
128
|
+
if packet.header.not_before is not None and current_time < packet.header.not_before:
|
|
129
|
+
raise TransportNotYetValidError("packet not valid yet")
|
|
130
|
+
|
|
131
|
+
if packet.header.expires_at is not None and current_time > packet.header.expires_at:
|
|
132
|
+
raise TransportExpiredError("packet TTL exceeded")
|
|
133
|
+
|
|
134
|
+
if packet.header.packet_type == "replay_request":
|
|
135
|
+
if not packet.header.replay_mode:
|
|
136
|
+
raise TransportValidationError("replay_request packets must set replay_mode=true")
|
|
137
|
+
if not replay_enabled:
|
|
138
|
+
raise TransportAuthorizationError("replay is disabled on this service")
|
|
139
|
+
|
|
140
|
+
if packet.header.replay_mode and packet.header.packet_type != "replay_request":
|
|
141
|
+
raise TransportValidationError("replay_mode is only permitted for replay_request packets")
|
|
142
|
+
|
|
143
|
+
if required_idempotency_actions and packet.header.action in set(required_idempotency_actions):
|
|
144
|
+
if not packet.header.idempotency_key:
|
|
145
|
+
raise TransportValidationError("idempotency_key required for this action")
|
|
146
|
+
|
|
147
|
+
if "GDPR" in packet.governance.compliance_tags and not packet.governance.data_subject_id:
|
|
148
|
+
raise TransportValidationError("GDPR packets require data_subject_id")
|
|
149
|
+
|
|
150
|
+
if (
|
|
151
|
+
packet.security.classification == "restricted"
|
|
152
|
+
and packet.governance.audit_required is not True
|
|
153
|
+
):
|
|
154
|
+
raise TransportValidationError("restricted packets must set audit_required=true")
|
|
155
|
+
|
|
156
|
+
_validate_delegation_chain(packet, now=current_time)
|
|
157
|
+
|
|
158
|
+
if packet.security.signature is None:
|
|
159
|
+
if (require_signature or packet.security.classification == "restricted") and not dev_mode:
|
|
160
|
+
raise TransportAuthenticationError("signature required but not present")
|
|
161
|
+
else:
|
|
162
|
+
if not (dev_mode and key_resolver is None):
|
|
163
|
+
if not verify_transport_packet_signature(packet, key_resolver=key_resolver):
|
|
164
|
+
raise TransportAuthenticationError("invalid transport signature")
|
|
165
|
+
|
|
166
|
+
validate_hop_trace(
|
|
167
|
+
packet,
|
|
168
|
+
verify_hop_signatures=verify_hop_signatures,
|
|
169
|
+
hop_key_resolver=hop_key_resolver,
|
|
170
|
+
require_monotonic_timestamps=require_monotonic_hop_timestamps,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _validate_delegation_chain(packet: TransportPacket, *, now: datetime) -> None:
|
|
175
|
+
previous_scope: set[str] | None = None
|
|
176
|
+
|
|
177
|
+
for index, link in enumerate(packet.delegation_chain):
|
|
178
|
+
current_scope = set(link.scope)
|
|
179
|
+
|
|
180
|
+
if previous_scope is not None and not current_scope.issubset(previous_scope):
|
|
181
|
+
raise TransportAuthorizationError("delegation scope escalation detected")
|
|
182
|
+
|
|
183
|
+
if link.expires_at is not None and now > link.expires_at.astimezone(UTC):
|
|
184
|
+
raise TransportAuthorizationError("delegation link expired")
|
|
185
|
+
|
|
186
|
+
if index == len(packet.delegation_chain) - 1 and packet.header.packet_type == "delegation":
|
|
187
|
+
if packet.header.action not in current_scope:
|
|
188
|
+
raise TransportAuthorizationError(
|
|
189
|
+
"delegated packet action not permitted by last delegation scope"
|
|
190
|
+
)
|
|
191
|
+
if packet.address.destination_node != link.delegatee:
|
|
192
|
+
raise TransportAuthorizationError(
|
|
193
|
+
"delegated packet destination does not match last delegation target"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
previous_scope = current_scope
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_attachment_uri(
|
|
200
|
+
uri: str, *, allowed_schemes: tuple[str, ...], allow_private_hosts: bool
|
|
201
|
+
) -> None:
|
|
202
|
+
parsed = urlparse(uri)
|
|
203
|
+
scheme = parsed.scheme.lower()
|
|
204
|
+
|
|
205
|
+
if not scheme:
|
|
206
|
+
raise TransportValidationError("attachment uri must include a scheme")
|
|
207
|
+
if allowed_schemes and scheme not in set(allowed_schemes):
|
|
208
|
+
raise TransportValidationError(f"attachment scheme not allowed: {scheme}")
|
|
209
|
+
if scheme in {"file", "ftp"}:
|
|
210
|
+
raise TransportValidationError(f"attachment scheme not allowed: {scheme}")
|
|
211
|
+
|
|
212
|
+
hostname = parsed.hostname
|
|
213
|
+
if hostname is None:
|
|
214
|
+
if scheme in {"https", "http"}:
|
|
215
|
+
raise TransportValidationError("http(s) attachment uri must include a host")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
normalized_host = hostname.lower()
|
|
219
|
+
if normalized_host in {"localhost", "metadata.google.internal"}:
|
|
220
|
+
raise TransportValidationError("attachment host not allowed")
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
host_ip = ipaddress.ip_address(normalized_host)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if not allow_private_hosts and (
|
|
228
|
+
host_ip.is_private or host_ip.is_loopback or host_ip.is_link_local
|
|
229
|
+
):
|
|
230
|
+
raise TransportValidationError("private attachment hosts are not allowed")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from cryptography.exceptions import InvalidSignature
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
10
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
11
|
+
|
|
12
|
+
from constellation_node_sdk.transport.errors import TransportAuthenticationError
|
|
13
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _coerce_bytes(value: str | bytes) -> bytes:
|
|
17
|
+
if isinstance(value, bytes):
|
|
18
|
+
return value
|
|
19
|
+
normalized = value.strip()
|
|
20
|
+
if normalized.startswith("-----BEGIN"):
|
|
21
|
+
return normalized.encode("utf-8")
|
|
22
|
+
try:
|
|
23
|
+
return base64.b64decode(normalized, validate=True)
|
|
24
|
+
except Exception:
|
|
25
|
+
return normalized.encode("utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_key(
|
|
29
|
+
key_resolver: Callable[[str | None], str | bytes | None]
|
|
30
|
+
| Mapping[str, str | bytes]
|
|
31
|
+
| str
|
|
32
|
+
| bytes
|
|
33
|
+
| None,
|
|
34
|
+
key_id: str | None,
|
|
35
|
+
) -> bytes | None:
|
|
36
|
+
if key_resolver is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(key_resolver, (bytes, str)):
|
|
39
|
+
return _coerce_bytes(key_resolver)
|
|
40
|
+
if callable(key_resolver):
|
|
41
|
+
value = key_resolver(key_id)
|
|
42
|
+
else:
|
|
43
|
+
if key_id is None:
|
|
44
|
+
return None
|
|
45
|
+
value = key_resolver.get(key_id)
|
|
46
|
+
if value is None:
|
|
47
|
+
return None
|
|
48
|
+
return _coerce_bytes(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_ed25519_public_key(raw: bytes) -> Ed25519PublicKey:
|
|
52
|
+
if raw.startswith(b"-----BEGIN"):
|
|
53
|
+
loaded = load_pem_public_key(raw)
|
|
54
|
+
if not isinstance(loaded, Ed25519PublicKey):
|
|
55
|
+
raise TransportAuthenticationError("PEM public key is not Ed25519")
|
|
56
|
+
return loaded
|
|
57
|
+
if len(raw) == 32:
|
|
58
|
+
return Ed25519PublicKey.from_public_bytes(raw)
|
|
59
|
+
raise TransportAuthenticationError("unsupported ed25519 public key format")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_transport_packet_signature(
|
|
63
|
+
packet: TransportPacket,
|
|
64
|
+
*,
|
|
65
|
+
key_resolver: Callable[[str | None], str | bytes | None]
|
|
66
|
+
| Mapping[str, str | bytes]
|
|
67
|
+
| str
|
|
68
|
+
| bytes
|
|
69
|
+
| None,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Verify the packet signature against packet.security.transport_hash.
|
|
73
|
+
|
|
74
|
+
Returns False for unsigned packets.
|
|
75
|
+
Raises TransportAuthenticationError for key resolution or verification failures.
|
|
76
|
+
"""
|
|
77
|
+
if packet.security.signature is None or packet.security.signature_algorithm is None:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
key = _resolve_key(key_resolver, packet.security.signing_key_id)
|
|
81
|
+
if key is None:
|
|
82
|
+
raise TransportAuthenticationError(
|
|
83
|
+
"no verifying key available for transport signature verification"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if packet.security.signature_algorithm == "hmac-sha256":
|
|
87
|
+
expected = hmac.new(
|
|
88
|
+
key,
|
|
89
|
+
packet.security.transport_hash.encode("utf-8"),
|
|
90
|
+
hashlib.sha256,
|
|
91
|
+
).hexdigest()
|
|
92
|
+
return hmac.compare_digest(packet.security.signature, expected)
|
|
93
|
+
|
|
94
|
+
if packet.security.signature_algorithm == "ed25519":
|
|
95
|
+
public_key = _load_ed25519_public_key(key)
|
|
96
|
+
try:
|
|
97
|
+
public_key.verify(
|
|
98
|
+
bytes.fromhex(packet.security.signature),
|
|
99
|
+
packet.security.transport_hash.encode("utf-8"),
|
|
100
|
+
)
|
|
101
|
+
return True
|
|
102
|
+
except (ValueError, InvalidSignature) as exc:
|
|
103
|
+
raise TransportAuthenticationError("invalid ed25519 transport signature") from exc
|
|
104
|
+
|
|
105
|
+
raise TransportAuthenticationError(
|
|
106
|
+
f"unsupported signature algorithm: {packet.security.signature_algorithm}"
|
|
107
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.transport.errors import (
|
|
4
|
+
PacketSizeError,
|
|
5
|
+
SchemaVersionError,
|
|
6
|
+
TenantMutationError,
|
|
7
|
+
TransportAuthenticationError,
|
|
8
|
+
TransportAuthorizationError,
|
|
9
|
+
TransportError,
|
|
10
|
+
TransportExpiredError,
|
|
11
|
+
TransportIntegrityError,
|
|
12
|
+
TransportNotYetValidError,
|
|
13
|
+
TransportValidationError,
|
|
14
|
+
)
|
|
15
|
+
from constellation_node_sdk.transport.hashing import (
|
|
16
|
+
canonical_json,
|
|
17
|
+
compute_payload_hash,
|
|
18
|
+
compute_transport_hash,
|
|
19
|
+
)
|
|
20
|
+
from constellation_node_sdk.transport.hop_trace import (
|
|
21
|
+
compute_hop_hash,
|
|
22
|
+
last_hop_hash,
|
|
23
|
+
make_dispatch_hop,
|
|
24
|
+
make_execution_hop,
|
|
25
|
+
make_ingress_hop,
|
|
26
|
+
make_response_hop,
|
|
27
|
+
sign_hop,
|
|
28
|
+
validate_hop_trace,
|
|
29
|
+
verify_hop_signature,
|
|
30
|
+
)
|
|
31
|
+
from constellation_node_sdk.transport.models import (
|
|
32
|
+
DelegationLink,
|
|
33
|
+
TransportAddress,
|
|
34
|
+
TransportAttachment,
|
|
35
|
+
TransportGovernance,
|
|
36
|
+
TransportHeader,
|
|
37
|
+
TransportHop,
|
|
38
|
+
TransportLineage,
|
|
39
|
+
TransportSecurity,
|
|
40
|
+
utc_now,
|
|
41
|
+
)
|
|
42
|
+
from constellation_node_sdk.transport.packet import TransportPacket, create_transport_packet
|
|
43
|
+
from constellation_node_sdk.transport.provenance import RoutingProvenance
|
|
44
|
+
from constellation_node_sdk.transport.tenant import (
|
|
45
|
+
TenantContext,
|
|
46
|
+
assert_tenant_immutable,
|
|
47
|
+
ensure_tenant_context,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DelegationLink",
|
|
52
|
+
"PacketSizeError",
|
|
53
|
+
"RoutingProvenance",
|
|
54
|
+
"SchemaVersionError",
|
|
55
|
+
"TenantContext",
|
|
56
|
+
"TenantMutationError",
|
|
57
|
+
"TransportAddress",
|
|
58
|
+
"TransportAttachment",
|
|
59
|
+
"TransportAuthenticationError",
|
|
60
|
+
"TransportAuthorizationError",
|
|
61
|
+
"TransportError",
|
|
62
|
+
"TransportExpiredError",
|
|
63
|
+
"TransportGovernance",
|
|
64
|
+
"TransportHeader",
|
|
65
|
+
"TransportHop",
|
|
66
|
+
"TransportIntegrityError",
|
|
67
|
+
"TransportLineage",
|
|
68
|
+
"TransportNotYetValidError",
|
|
69
|
+
"TransportPacket",
|
|
70
|
+
"TransportSecurity",
|
|
71
|
+
"TransportValidationError",
|
|
72
|
+
"assert_tenant_immutable",
|
|
73
|
+
"canonical_json",
|
|
74
|
+
"compute_hop_hash",
|
|
75
|
+
"compute_payload_hash",
|
|
76
|
+
"compute_transport_hash",
|
|
77
|
+
"create_transport_packet",
|
|
78
|
+
"ensure_tenant_context",
|
|
79
|
+
"last_hop_hash",
|
|
80
|
+
"make_dispatch_hop",
|
|
81
|
+
"make_execution_hop",
|
|
82
|
+
"make_ingress_hop",
|
|
83
|
+
"make_response_hop",
|
|
84
|
+
"sign_hop",
|
|
85
|
+
"utc_now",
|
|
86
|
+
"validate_hop_trace",
|
|
87
|
+
"verify_hop_signature",
|
|
88
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def encode_transport_packet(packet: TransportPacket) -> dict[str, Any]:
|
|
9
|
+
"""
|
|
10
|
+
Encode a TransportPacket into a JSON-safe dict.
|
|
11
|
+
"""
|
|
12
|
+
return packet.model_dump_json_dict()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode_transport_packet(payload: dict[str, Any]) -> TransportPacket:
|
|
16
|
+
"""
|
|
17
|
+
Decode a JSON object into a canonical TransportPacket.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(payload, dict):
|
|
20
|
+
raise ValueError("transport payload must be a JSON object")
|
|
21
|
+
return TransportPacket.model_validate(payload)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TransportError(Exception):
|
|
5
|
+
"""Base exception for transport contract failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TransportValidationError(TransportError):
|
|
9
|
+
"""Raised when a transport packet violates structural or semantic rules."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportIntegrityError(TransportValidationError):
|
|
13
|
+
"""Raised when payload or transport hashes do not match."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransportAuthenticationError(TransportValidationError):
|
|
17
|
+
"""Raised when transport authentication or signature state is invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransportAuthorizationError(TransportValidationError):
|
|
21
|
+
"""Raised when transport routing or delegation violates policy."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransportExpiredError(TransportValidationError):
|
|
25
|
+
"""Raised when a packet has exceeded its expiry window."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TransportNotYetValidError(TransportValidationError):
|
|
29
|
+
"""Raised when a packet is not valid yet due to not_before constraints."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TenantMutationError(TransportValidationError):
|
|
33
|
+
"""Raised when a derived packet mutates immutable tenant context."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SchemaVersionError(TransportValidationError):
|
|
37
|
+
"""Raised when an unsupported schema version is encountered."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PacketSizeError(TransportValidationError):
|
|
41
|
+
"""Raised when a packet exceeds maximum allowed size."""
|