contracts.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. """Immutable, fail-closed contracts for the enterprise edge boundary."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import math
  6. import re
  7. from collections.abc import Mapping
  8. from dataclasses import dataclass
  9. from dataclasses import field as dataclass_field
  10. from datetime import UTC, datetime, timedelta
  11. from decimal import Decimal, InvalidOperation
  12. from types import MappingProxyType
  13. from typing import ClassVar
  14. from cryptography.exceptions import InvalidSignature
  15. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
  16. class EdgeContractError(ValueError):
  17. """Raised when an edge contract is malformed or ambiguous."""
  18. TASK_OPERATIONS = frozenset(
  19. {"collect", "profile", "quality", "lineage", "controlled_query"}
  20. )
  21. TASK_CLASSIFICATIONS = frozenset(
  22. {
  23. "raw",
  24. "recent_detail",
  25. "restricted",
  26. "desensitized_metadata",
  27. "statistics",
  28. "lineage",
  29. "evidence",
  30. }
  31. )
  32. EVENT_CLASSIFICATIONS = frozenset(
  33. {
  34. "desensitized_metadata",
  35. "statistics",
  36. "lineage",
  37. "evidence",
  38. "health_summary",
  39. "diagnostic_summary",
  40. }
  41. )
  42. CONTRACT_VERSION = 1
  43. MAX_ATTEMPT = 5
  44. PREFLIGHT_MAX_DEPTH = 64
  45. PREFLIGHT_MAX_NODES = 10_000
  46. PREFLIGHT_MAX_STRING_BYTES = 131_072
  47. PREFLIGHT_MAX_TOTAL_BYTES = 1_048_576
  48. _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$")
  49. _SHA256 = re.compile(r"^[0-9a-f]{64}$")
  50. _ED25519_SIGNATURE = re.compile(r"^[0-9a-f]{128}$")
  51. _RFC3339 = re.compile(
  52. r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$"
  53. )
  54. def preflight_json(
  55. value: object,
  56. *,
  57. max_depth: int = PREFLIGHT_MAX_DEPTH,
  58. max_nodes: int = PREFLIGHT_MAX_NODES,
  59. max_string_bytes: int = PREFLIGHT_MAX_STRING_BYTES,
  60. max_total_bytes: int = PREFLIGHT_MAX_TOTAL_BYTES,
  61. ) -> None:
  62. """Bound a JSON-like graph iteratively before copying or encoding it."""
  63. nodes = 0
  64. estimated_bytes = 0
  65. active: set[int] = set()
  66. stack: list[tuple[bool, object, int]] = [(False, value, 0)]
  67. try:
  68. while stack:
  69. exiting, current, depth = stack.pop()
  70. if exiting:
  71. active.remove(id(current))
  72. continue
  73. if depth > max_depth:
  74. raise EdgeContractError("contract exceeds the maximum depth")
  75. nodes += 1
  76. if nodes > max_nodes:
  77. raise EdgeContractError("contract exceeds the maximum node count")
  78. if isinstance(current, str):
  79. size = len(current.encode("utf-8"))
  80. estimated_bytes += size + 16
  81. if estimated_bytes > max_total_bytes:
  82. raise EdgeContractError(
  83. "contract exceeds the conservative byte limit"
  84. )
  85. if size > max_string_bytes:
  86. raise EdgeContractError("contract string exceeds the byte limit")
  87. elif current is None or isinstance(current, bool):
  88. estimated_bytes += 8
  89. elif isinstance(current, int):
  90. if current.bit_length() > 4096:
  91. raise EdgeContractError("contract integer exceeds the byte limit")
  92. estimated_bytes += max(8, current.bit_length() // 3 + 4)
  93. elif isinstance(current, float):
  94. if not math.isfinite(current):
  95. raise EdgeContractError("contract numbers must be finite")
  96. estimated_bytes += 32
  97. elif isinstance(current, Mapping):
  98. identity = id(current)
  99. if identity in active:
  100. raise EdgeContractError("contract contains a cycle")
  101. active.add(identity)
  102. stack.append((True, current, depth))
  103. estimated_bytes += 16
  104. children: list[object] = []
  105. for key, item in current.items():
  106. if not isinstance(key, str):
  107. raise EdgeContractError("contract property names must be strings")
  108. key_size = len(key.encode("utf-8"))
  109. if key_size > max_string_bytes:
  110. raise EdgeContractError("contract key exceeds the byte limit")
  111. estimated_bytes += key_size + 8
  112. children.append(item)
  113. stack.extend((False, item, depth + 1) for item in reversed(children))
  114. elif isinstance(current, (list, tuple)):
  115. identity = id(current)
  116. if identity in active:
  117. raise EdgeContractError("contract contains a cycle")
  118. active.add(identity)
  119. stack.append((True, current, depth))
  120. estimated_bytes += 16
  121. stack.extend((False, item, depth + 1) for item in reversed(current))
  122. else:
  123. raise EdgeContractError("contract contains a non-JSON value")
  124. if estimated_bytes > max_total_bytes:
  125. raise EdgeContractError("contract exceeds the conservative byte limit")
  126. except (MemoryError, RecursionError) as exc:
  127. raise EdgeContractError("contract exceeds safe structural limits") from exc
  128. def _length_prefixed(tag: bytes, content: bytes) -> bytes:
  129. return tag + str(len(content)).encode("ascii") + b":" + content
  130. def _number_text(value: int | float) -> str:
  131. try:
  132. number = Decimal(value if isinstance(value, int) else str(value))
  133. except (InvalidOperation, ValueError) as exc:
  134. raise EdgeContractError("contract number is invalid") from exc
  135. if not number.is_finite():
  136. raise EdgeContractError("contract numbers must be finite")
  137. if number == 0:
  138. return "0"
  139. normalized = format(number.normalize(), "f")
  140. if "." in normalized:
  141. normalized = normalized.rstrip("0").rstrip(".")
  142. return normalized
  143. def _canonical_encode(value: object) -> bytes:
  144. if value is None:
  145. return b"n"
  146. if isinstance(value, bool):
  147. return b"b1" if value else b"b0"
  148. if isinstance(value, (int, float)):
  149. return _length_prefixed(b"d", _number_text(value).encode("ascii"))
  150. if isinstance(value, str):
  151. return _length_prefixed(b"s", value.encode("utf-8"))
  152. if isinstance(value, Mapping):
  153. encoded = bytearray(b"m" + str(len(value)).encode("ascii") + b":")
  154. for key in sorted(value, key=lambda item: item.encode("utf-8")):
  155. key_bytes = key.encode("utf-8")
  156. encoded.extend(_length_prefixed(b"k", key_bytes))
  157. encoded.extend(_canonical_encode(value[key]))
  158. return bytes(encoded)
  159. if isinstance(value, (list, tuple)):
  160. encoded = bytearray(b"l" + str(len(value)).encode("ascii") + b":")
  161. for item in value:
  162. encoded.extend(_canonical_encode(item))
  163. return bytes(encoded)
  164. raise EdgeContractError("contract contains a non-JSON value")
  165. def canonical_json_bytes(value: object) -> bytes:
  166. """Encode the explicit type-tagged, length-prefixed canonical protocol."""
  167. preflight_json(value)
  168. try:
  169. return _canonical_encode(value)
  170. except (MemoryError, RecursionError) as exc:
  171. raise EdgeContractError("contract exceeds safe encoding limits") from exc
  172. def canonical_sha256(value: object) -> str:
  173. return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
  174. def strict_json_bytes(value: object) -> bytes:
  175. """Serialize validated data as strict transport/storage JSON."""
  176. preflight_json(value)
  177. try:
  178. return json.dumps(
  179. value,
  180. sort_keys=True,
  181. separators=(",", ":"),
  182. ensure_ascii=False,
  183. allow_nan=False,
  184. ).encode("utf-8")
  185. except (MemoryError, RecursionError, TypeError, ValueError) as exc:
  186. raise EdgeContractError("contract cannot be serialized as strict JSON") from exc
  187. def stable_event_id(identity: Mapping[str, object]) -> str:
  188. if not isinstance(identity, Mapping):
  189. raise EdgeContractError("event identity must be a mapping")
  190. normalized = dict(identity)
  191. if "occurred_at" in normalized:
  192. normalized["occurred_at"] = _timestamp(
  193. normalized["occurred_at"], "occurred_at"
  194. )
  195. return f"evt_{canonical_sha256(normalized)}"
  196. def _strict_mapping(value: object, *, label: str) -> dict[str, object]:
  197. if not isinstance(value, Mapping):
  198. raise EdgeContractError(f"{label} must be a mapping")
  199. preflight_json(value)
  200. return dict(value)
  201. def _require_exact_properties(
  202. value: Mapping[str, object],
  203. *,
  204. required: frozenset[str],
  205. optional: frozenset[str] = frozenset(),
  206. ) -> None:
  207. keys = frozenset(value)
  208. unknown = keys - required - optional
  209. missing = required - keys
  210. if unknown:
  211. raise EdgeContractError(
  212. f"contract has unknown properties: {', '.join(sorted(unknown))}"
  213. )
  214. if missing:
  215. raise EdgeContractError(
  216. f"contract is missing properties: {', '.join(sorted(missing))}"
  217. )
  218. def _text(value: object, label: str, *, maximum: int = 255) -> str:
  219. if (
  220. not isinstance(value, str)
  221. or not value
  222. or value.strip() != value
  223. or len(value.encode("utf-8")) > maximum
  224. or "\x00" in value
  225. ):
  226. raise EdgeContractError(f"{label} is invalid")
  227. return value
  228. def _identifier(value: object, label: str) -> str:
  229. candidate = _text(value, label)
  230. if not _IDENTIFIER.fullmatch(candidate):
  231. raise EdgeContractError(f"{label} is invalid")
  232. return candidate
  233. def _attempt(value: object) -> int:
  234. if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 5:
  235. raise EdgeContractError("attempt must be between 1 and 5")
  236. return value
  237. def _version(value: object) -> int:
  238. if isinstance(value, bool) or value != CONTRACT_VERSION:
  239. raise EdgeContractError("contract_version is not supported")
  240. return CONTRACT_VERSION
  241. def _timestamp(value: object, label: str) -> str:
  242. candidate = _text(value, label, maximum=64)
  243. if not _RFC3339.fullmatch(candidate):
  244. raise EdgeContractError(f"{label} must be an RFC3339 timestamp")
  245. try:
  246. parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
  247. except ValueError as exc:
  248. raise EdgeContractError(f"{label} must be an RFC3339 timestamp") from exc
  249. if parsed.tzinfo is None or parsed.utcoffset() is None:
  250. raise EdgeContractError(f"{label} must be an RFC3339 timestamp")
  251. utc = parsed.astimezone(UTC)
  252. canonical = utc.strftime("%Y-%m-%dT%H:%M:%S")
  253. if utc.microsecond:
  254. canonical += f".{utc.microsecond:06d}".rstrip("0")
  255. return f"{canonical}Z"
  256. def canonical_timestamp(value: object, label: str = "timestamp") -> str:
  257. return _timestamp(value, label)
  258. def _digest(value: object, label: str) -> str:
  259. candidate = _text(value, label, maximum=64)
  260. if not _SHA256.fullmatch(candidate):
  261. raise EdgeContractError(f"{label} must be a lowercase SHA256 digest")
  262. return candidate
  263. def _json_copy(value: object) -> object:
  264. try:
  265. encoded = strict_json_bytes(value)
  266. return json.loads(encoded.decode("utf-8"))
  267. except (MemoryError, RecursionError, TypeError, ValueError) as exc:
  268. raise EdgeContractError("contract cannot be copied as strict JSON") from exc
  269. def _deep_freeze(value: object) -> object:
  270. if isinstance(value, dict):
  271. return MappingProxyType({key: _deep_freeze(item) for key, item in value.items()})
  272. if isinstance(value, list):
  273. return tuple(_deep_freeze(item) for item in value)
  274. return value
  275. def _deep_thaw(value: object) -> object:
  276. if isinstance(value, Mapping):
  277. return {key: _deep_thaw(item) for key, item in value.items()}
  278. if isinstance(value, tuple):
  279. return [_deep_thaw(item) for item in value]
  280. return value
  281. @dataclass(frozen=True, slots=True)
  282. class EdgeTaskContract:
  283. task_id: str
  284. gateway_id: str
  285. environment: str
  286. network_zone: str
  287. purpose: str
  288. classification: str
  289. operation: str
  290. contract_version: int
  291. deadline_at: str
  292. attempt: int
  293. idempotency_key: str
  294. policy_digest: str
  295. def __post_init__(self) -> None:
  296. values = {
  297. "task_id": _identifier(self.task_id, "task_id"),
  298. "gateway_id": _identifier(self.gateway_id, "gateway_id"),
  299. "environment": _identifier(self.environment, "environment"),
  300. "network_zone": _identifier(self.network_zone, "network_zone"),
  301. "purpose": _identifier(self.purpose, "purpose"),
  302. "contract_version": _version(self.contract_version),
  303. "deadline_at": _timestamp(self.deadline_at, "deadline_at"),
  304. "attempt": _attempt(self.attempt),
  305. "idempotency_key": _identifier(self.idempotency_key, "idempotency_key"),
  306. "policy_digest": _digest(self.policy_digest, "policy_digest"),
  307. }
  308. if self.operation not in TASK_OPERATIONS:
  309. raise EdgeContractError("task operation is not approved")
  310. if self.classification not in TASK_CLASSIFICATIONS:
  311. raise EdgeContractError("task classification is invalid")
  312. for field, value in values.items():
  313. object.__setattr__(self, field, value)
  314. @property
  315. def task_type(self) -> str:
  316. return self.operation
  317. @classmethod
  318. def from_mapping(cls, value: Mapping[str, object]) -> EdgeTaskContract:
  319. data = _strict_mapping(value, label="task contract")
  320. required = frozenset(
  321. {
  322. "task_id",
  323. "gateway_id",
  324. "environment",
  325. "network_zone",
  326. "purpose",
  327. "classification",
  328. "contract_version",
  329. "deadline_at",
  330. "attempt",
  331. "idempotency_key",
  332. "policy_digest",
  333. }
  334. )
  335. _require_exact_properties(
  336. data,
  337. required=required,
  338. optional=frozenset({"task_type", "operation"}),
  339. )
  340. if ("task_type" in data) == ("operation" in data):
  341. raise EdgeContractError(
  342. "task contract requires exactly one operation property"
  343. )
  344. return cls(
  345. task_id=data["task_id"], # type: ignore[arg-type]
  346. gateway_id=data["gateway_id"], # type: ignore[arg-type]
  347. environment=data["environment"], # type: ignore[arg-type]
  348. network_zone=data["network_zone"], # type: ignore[arg-type]
  349. purpose=data["purpose"], # type: ignore[arg-type]
  350. classification=data["classification"], # type: ignore[arg-type]
  351. operation=data.get("task_type", data.get("operation")), # type: ignore[arg-type]
  352. contract_version=data["contract_version"], # type: ignore[arg-type]
  353. deadline_at=data["deadline_at"], # type: ignore[arg-type]
  354. attempt=data["attempt"], # type: ignore[arg-type]
  355. idempotency_key=data["idempotency_key"], # type: ignore[arg-type]
  356. policy_digest=data["policy_digest"], # type: ignore[arg-type]
  357. )
  358. def to_mapping(self) -> dict[str, object]:
  359. return {
  360. "task_id": self.task_id,
  361. "gateway_id": self.gateway_id,
  362. "environment": self.environment,
  363. "network_zone": self.network_zone,
  364. "purpose": self.purpose,
  365. "classification": self.classification,
  366. "task_type": self.operation,
  367. "contract_version": self.contract_version,
  368. "deadline_at": self.deadline_at,
  369. "attempt": self.attempt,
  370. "idempotency_key": self.idempotency_key,
  371. "policy_digest": self.policy_digest,
  372. }
  373. @property
  374. def digest(self) -> str:
  375. return canonical_sha256(self.to_mapping())
  376. @dataclass(frozen=True, slots=True)
  377. class SignedTaskEnvelope:
  378. """Control-plane authority over an exact, context-bound task contract."""
  379. task: EdgeTaskContract
  380. authority_key_id: str
  381. signature_algorithm: str
  382. contract_digest: str
  383. gateway_id: str
  384. environment: str
  385. network_zone: str
  386. policy_digest: str
  387. purpose: str
  388. issued_at: str
  389. expires_at: str
  390. signature: str
  391. _verified: bool = dataclass_field(default=False, init=False, repr=False, compare=False)
  392. _UNSIGNED_FIELDS: ClassVar[frozenset[str]] = frozenset(
  393. {
  394. "task", "authority_key_id", "signature_algorithm", "contract_digest",
  395. "gateway_id", "environment", "network_zone", "policy_digest", "purpose",
  396. "issued_at", "expires_at",
  397. }
  398. )
  399. def __post_init__(self) -> None:
  400. if not isinstance(self.task, EdgeTaskContract):
  401. raise EdgeContractError("signed task envelope requires a task contract")
  402. normalized_task = EdgeTaskContract.from_mapping(self.task.to_mapping())
  403. unsigned = {
  404. "task": normalized_task.to_mapping(),
  405. "authority_key_id": self.authority_key_id,
  406. "signature_algorithm": self.signature_algorithm,
  407. "contract_digest": self.contract_digest,
  408. "gateway_id": self.gateway_id,
  409. "environment": self.environment,
  410. "network_zone": self.network_zone,
  411. "policy_digest": self.policy_digest,
  412. "purpose": self.purpose,
  413. "issued_at": self.issued_at,
  414. "expires_at": self.expires_at,
  415. }
  416. self.canonical_unsigned_bytes(unsigned)
  417. if not isinstance(self.signature, str) or not _ED25519_SIGNATURE.fullmatch(self.signature):
  418. raise EdgeContractError("signature must be a lowercase Ed25519 signature")
  419. object.__setattr__(self, "task", normalized_task)
  420. @classmethod
  421. def canonical_unsigned_bytes(cls, value: Mapping[str, object]) -> bytes:
  422. data = _strict_mapping(value, label="signed task envelope")
  423. _require_exact_properties(data, required=cls._UNSIGNED_FIELDS)
  424. task_raw = data["task"]
  425. if isinstance(task_raw, EdgeTaskContract):
  426. task_raw = task_raw.to_mapping()
  427. task = EdgeTaskContract.from_mapping(task_raw) # type: ignore[arg-type]
  428. normalized = {
  429. "task": task.to_mapping(),
  430. "authority_key_id": _identifier(data["authority_key_id"], "authority_key_id"),
  431. "signature_algorithm": data["signature_algorithm"],
  432. "contract_digest": _digest(data["contract_digest"], "contract_digest"),
  433. "gateway_id": _identifier(data["gateway_id"], "gateway_id"),
  434. "environment": _identifier(data["environment"], "environment"),
  435. "network_zone": _identifier(data["network_zone"], "network_zone"),
  436. "policy_digest": _digest(data["policy_digest"], "policy_digest"),
  437. "purpose": _identifier(data["purpose"], "purpose"),
  438. "issued_at": _timestamp(data["issued_at"], "issued_at"),
  439. "expires_at": _timestamp(data["expires_at"], "expires_at"),
  440. }
  441. if normalized["signature_algorithm"] != "Ed25519":
  442. raise EdgeContractError("signature_algorithm is not supported")
  443. for field in ("gateway_id", "environment", "network_zone", "policy_digest", "purpose"):
  444. if normalized[field] != task.to_mapping()[field]:
  445. raise EdgeContractError(f"signed authority {field} does not bind the task")
  446. if normalized["contract_digest"] != task.digest:
  447. raise EdgeContractError("contract_digest does not bind the task")
  448. issued = datetime.fromisoformat(str(normalized["issued_at"]).replace("Z", "+00:00"))
  449. expires = datetime.fromisoformat(str(normalized["expires_at"]).replace("Z", "+00:00"))
  450. deadline = datetime.fromisoformat(task.deadline_at.replace("Z", "+00:00"))
  451. if expires <= issued or expires > deadline:
  452. raise EdgeContractError("signed authority validity window is invalid")
  453. return canonical_json_bytes(normalized)
  454. @classmethod
  455. def verify_mapping(
  456. cls,
  457. value: Mapping[str, object],
  458. *,
  459. authority_keys: Mapping[str, bytes | Ed25519PublicKey],
  460. now: datetime,
  461. allowed_future_skew_seconds: int = 0,
  462. ) -> SignedTaskEnvelope:
  463. data = _strict_mapping(value, label="signed task envelope")
  464. _require_exact_properties(data, required=cls._UNSIGNED_FIELDS | {"signature"})
  465. if not isinstance(now, datetime) or now.tzinfo is None or now.utcoffset() is None:
  466. raise EdgeContractError("verification time must be timezone-aware")
  467. if (
  468. isinstance(allowed_future_skew_seconds, bool)
  469. or not isinstance(allowed_future_skew_seconds, int)
  470. or not 0 <= allowed_future_skew_seconds <= 300
  471. ):
  472. raise EdgeContractError("task authority clock skew is invalid")
  473. signature = _text(data["signature"], "signature", maximum=128)
  474. if not _ED25519_SIGNATURE.fullmatch(signature):
  475. raise EdgeContractError("signature must be a lowercase Ed25519 signature")
  476. unsigned = {key: data[key] for key in cls._UNSIGNED_FIELDS}
  477. signed_bytes = cls.canonical_unsigned_bytes(unsigned)
  478. key_id = _identifier(data["authority_key_id"], "authority_key_id")
  479. key = authority_keys.get(key_id)
  480. if key is None:
  481. raise EdgeContractError("task authority key is not trusted")
  482. try:
  483. public_key = (
  484. key if isinstance(key, Ed25519PublicKey)
  485. else Ed25519PublicKey.from_public_bytes(bytes(key))
  486. )
  487. public_key.verify(bytes.fromhex(signature), signed_bytes)
  488. except (InvalidSignature, TypeError, ValueError) as exc:
  489. raise EdgeContractError("task authority signature is invalid") from exc
  490. issued_at = _timestamp(data["issued_at"], "issued_at")
  491. expires_at = _timestamp(data["expires_at"], "expires_at")
  492. instant = now.astimezone(UTC)
  493. issued = datetime.fromisoformat(issued_at.replace("Z", "+00:00"))
  494. expires = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
  495. if instant + timedelta(seconds=allowed_future_skew_seconds) < issued:
  496. raise EdgeContractError("signed task is not yet valid")
  497. if instant >= expires:
  498. raise EdgeContractError("signed task authority has expired")
  499. task = EdgeTaskContract.from_mapping(data["task"]) # type: ignore[arg-type]
  500. envelope = cls(
  501. task=task,
  502. authority_key_id=key_id,
  503. signature_algorithm="Ed25519",
  504. contract_digest=task.digest,
  505. gateway_id=task.gateway_id,
  506. environment=task.environment,
  507. network_zone=task.network_zone,
  508. policy_digest=task.policy_digest,
  509. purpose=task.purpose,
  510. issued_at=issued_at,
  511. expires_at=expires_at,
  512. signature=signature,
  513. )
  514. object.__setattr__(envelope, "_verified", True)
  515. return envelope
  516. @property
  517. def is_verified(self) -> bool:
  518. return self._verified
  519. def to_mapping(self) -> dict[str, object]:
  520. return {
  521. "task": self.task.to_mapping(),
  522. "authority_key_id": self.authority_key_id,
  523. "signature_algorithm": self.signature_algorithm,
  524. "contract_digest": self.contract_digest,
  525. "gateway_id": self.gateway_id,
  526. "environment": self.environment,
  527. "network_zone": self.network_zone,
  528. "policy_digest": self.policy_digest,
  529. "purpose": self.purpose,
  530. "issued_at": self.issued_at,
  531. "expires_at": self.expires_at,
  532. "signature": self.signature,
  533. }
  534. @property
  535. def digest(self) -> str:
  536. return canonical_sha256(self.to_mapping())
  537. @dataclass(frozen=True, slots=True)
  538. class EdgeEventContract:
  539. event_id: str
  540. task_id: str
  541. gateway_id: str
  542. environment: str
  543. network_zone: str
  544. purpose: str
  545. classification: str
  546. contract_version: int
  547. occurred_at: str
  548. attempt: int
  549. idempotency_key: str
  550. policy_digest: str
  551. payload: Mapping[str, object]
  552. def __post_init__(self) -> None:
  553. payload = _strict_mapping(self.payload, label="event payload")
  554. payload_copy = _json_copy(payload)
  555. if not isinstance(payload_copy, dict):
  556. raise EdgeContractError("event payload must be a mapping")
  557. values = {
  558. "task_id": _identifier(self.task_id, "task_id"),
  559. "gateway_id": _identifier(self.gateway_id, "gateway_id"),
  560. "environment": _identifier(self.environment, "environment"),
  561. "network_zone": _identifier(self.network_zone, "network_zone"),
  562. "purpose": _identifier(self.purpose, "purpose"),
  563. "contract_version": _version(self.contract_version),
  564. "occurred_at": _timestamp(self.occurred_at, "occurred_at"),
  565. "attempt": _attempt(self.attempt),
  566. "idempotency_key": _identifier(self.idempotency_key, "idempotency_key"),
  567. "policy_digest": _digest(self.policy_digest, "policy_digest"),
  568. }
  569. if self.classification not in EVENT_CLASSIFICATIONS:
  570. raise EdgeContractError("event classification is not approved")
  571. identity = {
  572. "task_id": values["task_id"],
  573. "gateway_id": values["gateway_id"],
  574. "environment": values["environment"],
  575. "network_zone": values["network_zone"],
  576. "purpose": values["purpose"],
  577. "classification": self.classification,
  578. "contract_version": values["contract_version"],
  579. "occurred_at": values["occurred_at"],
  580. "attempt": values["attempt"],
  581. "idempotency_key": values["idempotency_key"],
  582. "policy_digest": values["policy_digest"],
  583. "payload": payload_copy,
  584. }
  585. expected_id = stable_event_id(identity)
  586. if self.event_id != expected_id:
  587. raise EdgeContractError("event_id is not the stable event identifier")
  588. object.__setattr__(self, "event_id", expected_id)
  589. object.__setattr__(self, "payload", _deep_freeze(payload_copy))
  590. for field, value in values.items():
  591. object.__setattr__(self, field, value)
  592. @classmethod
  593. def from_mapping(cls, value: Mapping[str, object]) -> EdgeEventContract:
  594. data = _strict_mapping(value, label="event contract")
  595. required = frozenset(
  596. {
  597. "event_id",
  598. "task_id",
  599. "gateway_id",
  600. "environment",
  601. "network_zone",
  602. "purpose",
  603. "classification",
  604. "contract_version",
  605. "occurred_at",
  606. "attempt",
  607. "idempotency_key",
  608. "policy_digest",
  609. "payload",
  610. }
  611. )
  612. _require_exact_properties(data, required=required)
  613. return cls(**data) # type: ignore[arg-type]
  614. def to_mapping(self) -> dict[str, object]:
  615. return {
  616. "event_id": self.event_id,
  617. "task_id": self.task_id,
  618. "gateway_id": self.gateway_id,
  619. "environment": self.environment,
  620. "network_zone": self.network_zone,
  621. "purpose": self.purpose,
  622. "classification": self.classification,
  623. "contract_version": self.contract_version,
  624. "occurred_at": self.occurred_at,
  625. "attempt": self.attempt,
  626. "idempotency_key": self.idempotency_key,
  627. "policy_digest": self.policy_digest,
  628. "payload": _deep_thaw(self.payload),
  629. }
  630. @property
  631. def digest(self) -> str:
  632. return canonical_sha256(self.to_mapping())