bootstrap.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """Fail-closed bootstrap configuration for an enterprise edge agent."""
  2. from __future__ import annotations
  3. import re
  4. import ssl
  5. import stat
  6. from collections.abc import Mapping, Sequence
  7. from dataclasses import dataclass, field
  8. from datetime import UTC, datetime
  9. from pathlib import Path
  10. from types import MappingProxyType
  11. from cryptography import x509
  12. from cryptography.hazmat.primitives import hashes, serialization
  13. from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, padding, rsa
  14. from app.core.edge_gateway.policy import EdgeEgressPolicy
  15. _SHA256 = re.compile(r"^[0-9a-f]{64}$")
  16. _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$")
  17. _VERSION = re.compile(r"^(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})$")
  18. _PLACEHOLDERS = {"change-me", "changeme", "example", "placeholder", "tbd", "todo"}
  19. _REQUIRED = frozenset(
  20. {
  21. "queue_path",
  22. "artifact_root",
  23. "gateway_id",
  24. "credential",
  25. "certificate_sha256",
  26. "client_certificate_path",
  27. "client_private_key_path",
  28. "ca_bundle_path",
  29. "generation",
  30. "environment",
  31. "network_zone",
  32. "policy_digest",
  33. "control_url",
  34. "proxy_url",
  35. "allowed_control_hosts",
  36. "allowed_proxy_hosts",
  37. "trusted_release_keys",
  38. "trusted_task_keys",
  39. "task_authority_clock_skew_seconds",
  40. "version",
  41. }
  42. )
  43. _OPTIONAL = frozenset({"server_crl_path", "allowed_control_origins", "allowed_proxy_origins"})
  44. def _text(value: object, label: str, maximum: int = 255) -> str:
  45. if (
  46. not isinstance(value, str)
  47. or not value
  48. or value.strip() != value
  49. or "\x00" in value
  50. or len(value.encode("utf-8")) > maximum
  51. or value.casefold() in _PLACEHOLDERS
  52. ):
  53. raise ValueError(f"{label} is not explicitly configured")
  54. return value
  55. def _identifier(value: object, label: str) -> str:
  56. candidate = _text(value, label)
  57. if not _IDENTIFIER.fullmatch(candidate):
  58. raise ValueError(f"{label} is invalid")
  59. return candidate
  60. def _hosts(value: object, label: str, *, required: bool) -> frozenset[str]:
  61. if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
  62. raise ValueError(f"{label} must be an explicit sequence")
  63. items = [_text(item, label) for item in value]
  64. if any(item != item.lower() for item in items):
  65. raise ValueError(f"{label} must contain lowercase exact hosts")
  66. result = frozenset(items)
  67. if required and not result:
  68. raise ValueError(f"{label} is required")
  69. if len(result) != len(value):
  70. raise ValueError(f"{label} contains duplicates")
  71. return result
  72. @dataclass(frozen=True, slots=True)
  73. class EdgeBootstrapConfig:
  74. queue_path: str
  75. artifact_root: str
  76. gateway_id: str
  77. credential: str = field(repr=False)
  78. certificate_sha256: str
  79. client_certificate_path: str
  80. client_private_key_path: str = field(repr=False)
  81. ca_bundle_path: str
  82. generation: int
  83. environment: str
  84. network_zone: str
  85. policy_digest: str
  86. control_url: str
  87. proxy_url: str | None
  88. allowed_control_hosts: frozenset[str]
  89. allowed_proxy_hosts: frozenset[str]
  90. trusted_release_keys: Mapping[str, str] = field(repr=False)
  91. trusted_task_keys: Mapping[str, str] = field(repr=False)
  92. version: str
  93. task_authority_clock_skew_seconds: int = 0
  94. server_crl_path: str | None = None
  95. allowed_control_origins: frozenset[str] = frozenset()
  96. allowed_proxy_origins: frozenset[str] = frozenset()
  97. @classmethod
  98. def from_mapping(cls, value: Mapping[str, object]) -> EdgeBootstrapConfig:
  99. if (
  100. not isinstance(value, Mapping)
  101. or not set(value) >= _REQUIRED
  102. or not set(value) <= _REQUIRED | _OPTIONAL
  103. ):
  104. raise ValueError("edge bootstrap requires the exact configuration fields")
  105. queue = _text(value["queue_path"], "queue_path", 2_048)
  106. if queue == ":memory:" or "mode=memory" in queue:
  107. raise ValueError("edge queue must use an explicit disk path")
  108. queue_path = Path(queue).expanduser()
  109. if not queue_path.is_absolute():
  110. raise ValueError("edge queue path must be absolute")
  111. artifact_root = Path(
  112. _text(value["artifact_root"], "artifact_root", 2_048)
  113. ).expanduser()
  114. if (
  115. not artifact_root.is_absolute()
  116. or not artifact_root.is_dir()
  117. or artifact_root.is_symlink()
  118. ):
  119. raise ValueError("artifact_root must be an existing absolute directory")
  120. credential = _text(value["credential"], "credential", 160)
  121. if (
  122. not credential.startswith("dopg_")
  123. or len(credential) < 16
  124. or any(marker in credential.casefold() for marker in _PLACEHOLDERS)
  125. ):
  126. raise ValueError("credential is invalid")
  127. certificate = _text(value["certificate_sha256"], "certificate_sha256", 64)
  128. policy_digest = _text(value["policy_digest"], "policy_digest", 64)
  129. if not _SHA256.fullmatch(certificate) or not _SHA256.fullmatch(policy_digest):
  130. raise ValueError("edge digest binding is invalid")
  131. material: dict[str, Path] = {}
  132. for field_name in (
  133. "client_certificate_path",
  134. "client_private_key_path",
  135. "ca_bundle_path",
  136. ):
  137. candidate = Path(_text(value[field_name], field_name, 2_048)).expanduser()
  138. if not candidate.is_absolute() or not candidate.is_file():
  139. raise ValueError(f"{field_name} must be an existing absolute file")
  140. material[field_name] = candidate.resolve()
  141. key_mode = stat.S_IMODE(material["client_private_key_path"].stat().st_mode)
  142. if key_mode & 0o077:
  143. raise ValueError("client private key permissions are not secure")
  144. try:
  145. certificate_object = x509.load_pem_x509_certificate(
  146. material["client_certificate_path"].read_bytes()
  147. )
  148. ca_object = x509.load_pem_x509_certificate(
  149. material["ca_bundle_path"].read_bytes()
  150. )
  151. private_key = serialization.load_pem_private_key(
  152. material["client_private_key_path"].read_bytes(), password=None
  153. )
  154. except (ValueError, OSError) as exc:
  155. raise ValueError("mTLS material is invalid") from exc
  156. actual_fingerprint = certificate_object.fingerprint(hashes.SHA256()).hex()
  157. if actual_fingerprint != certificate:
  158. raise ValueError("client certificate fingerprint does not match configuration")
  159. public_encoding = {
  160. "encoding": serialization.Encoding.DER,
  161. "format": serialization.PublicFormat.SubjectPublicKeyInfo,
  162. }
  163. if certificate_object.public_key().public_bytes(**public_encoding) != private_key.public_key().public_bytes(**public_encoding):
  164. raise ValueError("client private key does not match certificate")
  165. now = datetime.now(UTC)
  166. if certificate_object.not_valid_before_utc > now or certificate_object.not_valid_after_utc <= now:
  167. raise ValueError("client certificate is not currently valid")
  168. if ca_object.not_valid_after_utc <= now:
  169. raise ValueError("CA bundle certificate is expired")
  170. generation = value["generation"]
  171. if isinstance(generation, bool) or not isinstance(generation, int) or generation < 1:
  172. raise ValueError("generation is invalid")
  173. control_hosts = _hosts(value["allowed_control_hosts"], "allowed_control_hosts", required=True)
  174. proxy_hosts = _hosts(value["allowed_proxy_hosts"], "allowed_proxy_hosts", required=False)
  175. proxy_url = value["proxy_url"]
  176. if proxy_url is not None:
  177. proxy_url = _text(proxy_url, "proxy_url", 2_048)
  178. control_url = _text(value["control_url"], "control_url", 2_048)
  179. def origins(field_name: str) -> frozenset[str]:
  180. raw = value.get(field_name, [])
  181. if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
  182. raise ValueError(f"{field_name} must be an explicit sequence")
  183. result = frozenset(_text(item, field_name, 2_048) for item in raw)
  184. if len(result) != len(raw) or any(not item.startswith("https://") for item in result):
  185. raise ValueError(f"{field_name} is invalid")
  186. return result
  187. control_origins = origins("allowed_control_origins")
  188. proxy_origins = origins("allowed_proxy_origins")
  189. EdgeEgressPolicy(
  190. allowed_control_hosts=set(control_hosts),
  191. allowed_proxy_hosts=set(proxy_hosts),
  192. allowed_control_origins=set(control_origins) or None,
  193. allowed_proxy_origins=set(proxy_origins) or None,
  194. ).validate_destination(control_url, proxy_url=proxy_url)
  195. def trusted_keys(field_name: str) -> Mapping[str, str]:
  196. raw_keys = value[field_name]
  197. if not isinstance(raw_keys, Mapping) or not raw_keys:
  198. raise ValueError(f"{field_name} is required")
  199. keys: dict[str, str] = {}
  200. for raw_key_id, raw_public_key in raw_keys.items():
  201. key_id = _identifier(raw_key_id, "signing key id")
  202. public_key = _text(raw_public_key, "signing public key", 128)
  203. try:
  204. decoded = bytes.fromhex(public_key)
  205. except ValueError as exc:
  206. raise ValueError("signing public key must be lowercase hex") from exc
  207. if (
  208. public_key != public_key.lower()
  209. or len(decoded) != 32
  210. or decoded == bytes(32)
  211. ):
  212. raise ValueError("signing public key must be a 32-byte Ed25519 key")
  213. keys[key_id] = public_key
  214. return MappingProxyType(keys)
  215. release_keys = trusted_keys("trusted_release_keys")
  216. task_keys = trusted_keys("trusted_task_keys")
  217. task_authority_clock_skew_seconds = value[
  218. "task_authority_clock_skew_seconds"
  219. ]
  220. if (
  221. isinstance(task_authority_clock_skew_seconds, bool)
  222. or not isinstance(task_authority_clock_skew_seconds, int)
  223. or not 0 <= task_authority_clock_skew_seconds <= 300
  224. ):
  225. raise ValueError("task authority clock skew is invalid")
  226. version = _text(value["version"], "version", 80)
  227. if (
  228. not _VERSION.fullmatch(version)
  229. or any(int(part) > 2_147_483_647 for part in version.split("."))
  230. ):
  231. raise ValueError("version is invalid")
  232. server_crl_path: str | None = None
  233. if "server_crl_path" in value:
  234. candidate = Path(
  235. _text(value["server_crl_path"], "server_crl_path", 2_048)
  236. ).expanduser()
  237. if not candidate.is_absolute() or not candidate.is_file():
  238. raise ValueError("server_crl_path must be an existing absolute file")
  239. server_crl_path = str(candidate.resolve())
  240. validate_server_crl(str(material["ca_bundle_path"]), server_crl_path)
  241. return cls(
  242. queue_path=str(queue_path.resolve()),
  243. artifact_root=str(artifact_root.resolve()),
  244. gateway_id=_identifier(value["gateway_id"], "gateway_id"),
  245. credential=credential,
  246. certificate_sha256=certificate,
  247. client_certificate_path=str(material["client_certificate_path"]),
  248. client_private_key_path=str(material["client_private_key_path"]),
  249. ca_bundle_path=str(material["ca_bundle_path"]),
  250. generation=generation,
  251. environment=_identifier(value["environment"], "environment"),
  252. network_zone=_identifier(value["network_zone"], "network_zone"),
  253. policy_digest=policy_digest,
  254. control_url=control_url.rstrip("/"),
  255. proxy_url=proxy_url,
  256. allowed_control_hosts=control_hosts,
  257. allowed_proxy_hosts=proxy_hosts,
  258. trusted_release_keys=release_keys,
  259. trusted_task_keys=task_keys,
  260. version=version,
  261. task_authority_clock_skew_seconds=task_authority_clock_skew_seconds,
  262. server_crl_path=server_crl_path,
  263. allowed_control_origins=control_origins,
  264. allowed_proxy_origins=proxy_origins,
  265. )
  266. def _verify_crl_signature(crl: x509.CertificateRevocationList, issuer) -> None:
  267. public_key = issuer.public_key()
  268. try:
  269. if isinstance(public_key, rsa.RSAPublicKey):
  270. public_key.verify(
  271. crl.signature, crl.tbs_certlist_bytes, padding.PKCS1v15(),
  272. crl.signature_hash_algorithm,
  273. )
  274. elif isinstance(public_key, ec.EllipticCurvePublicKey):
  275. public_key.verify(
  276. crl.signature, crl.tbs_certlist_bytes,
  277. ec.ECDSA(crl.signature_hash_algorithm),
  278. )
  279. elif isinstance(public_key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
  280. public_key.verify(crl.signature, crl.tbs_certlist_bytes)
  281. else:
  282. raise ValueError("server CRL issuer key is unsupported")
  283. except Exception as exc:
  284. raise ValueError("server CRL signature is invalid") from exc
  285. def validate_server_crl(ca_bundle_path: str, server_crl_path: str) -> datetime:
  286. try:
  287. authorities = x509.load_pem_x509_certificates(Path(ca_bundle_path).read_bytes())
  288. crl = x509.load_pem_x509_crl(Path(server_crl_path).read_bytes())
  289. except (OSError, ValueError) as exc:
  290. raise ValueError("server CRL material is invalid") from exc
  291. issuer = next((item for item in authorities if item.subject == crl.issuer), None)
  292. if issuer is None:
  293. raise ValueError("server CRL issuer is not in the CA bundle")
  294. _verify_crl_signature(crl, issuer)
  295. now = datetime.now(UTC)
  296. if (
  297. crl.last_update_utc > now
  298. or crl.next_update_utc is None
  299. or crl.next_update_utc <= now
  300. ):
  301. raise ValueError("server CRL is not fresh")
  302. return crl.next_update_utc
  303. def build_server_crl_ssl_context(
  304. ca_bundle_path: str, server_crl_path: str
  305. ) -> ssl.SSLContext:
  306. validate_server_crl(ca_bundle_path, server_crl_path)
  307. context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_bundle_path)
  308. context.load_verify_locations(cafile=server_crl_path)
  309. context.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF
  310. context.check_hostname = True
  311. return context