For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.
contracts/e2e_contracts.py
| 1 | """ALL 2.0 contracts, Python reference verifier (the contract). |
| 2 | |
| 3 | The spec is `contracts/policy.json` (every constant), `contracts/schemas/` (shapes, line grammar) and `contracts/vectors/` (golden inputs and the |
| 4 | verdict every verifier must give). This module is the Python implementation of the checks the vectors exercise; the Go one is |
| 5 | `tunnel/contracts_test.go` and the TypeScript one is `contracts/ts/verify.ts`. All three give the same verdict AND the same error word for |
| 6 | every vector (the contract test suite, tunnel/contracts_test.go, the Worker contract test). It is a reference for tests and |
| 7 | for the Python fake node (fake_node.py): production code is written by the packages that own it, and reads the numbers from policy.json. |
| 8 | |
| 9 | What it checks, in this order of failure (the first failing step names the error): |
| 10 | * lines parse_line / format_line (the wire grammar of schemas/wire-v2.grammar.json) |
| 11 | * bodies Schemas.validate (the JSON Schema subset the schemas use) and check_feed (the cross-field rules of the feed) |
| 12 | * tokens verify_session, verify_proof (DPoP), verify_node_assertion, verify_statement, parse_jwks, parse_node_keys |
| 13 | * surface surface_mac / surface_proof (the HMAC identity proof of the E2E listener) |
| 14 | * names label_ok / fqdn_ok |
| 15 | * nodes check_transcript (does a recorded node behave as the contract demands: renew re-authorizes, host from the control plane only...) |
| 16 | Pure functions: the clock, the replay set and the key sets are arguments, never globals. |
| 17 | """ |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import base64 |
| 21 | import hashlib |
| 22 | import hmac |
| 23 | import ipaddress |
| 24 | import json |
| 25 | import re |
| 26 | from dataclasses import dataclass, field |
| 27 | from pathlib import Path |
| 28 | from typing import Any |
| 29 | |
| 30 | from cryptography.exceptions import InvalidSignature |
| 31 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey |
| 32 | |
| 33 | ROOT = Path(__file__).resolve().parent |
| 34 | POLICY_PATH = ROOT / "policy.json" |
| 35 | SCHEMA_DIR = ROOT / "schemas" |
| 36 | VECTOR_DIR = ROOT / "vectors" |
| 37 | |
| 38 | |
| 39 | def load_policy(path: Path | None = None) -> dict[str, Any]: |
| 40 | return json.loads((path or POLICY_PATH).read_text(encoding="utf-8")) |
| 41 | |
| 42 | |
| 43 | POLICY = load_policy() |
| 44 | |
| 45 | |
| 46 | def pol(dotted: str, policy: dict[str, Any] | None = None) -> Any: |
| 47 | """`pol("protocol.line_max_bytes")`: the one way this module reads a number.""" |
| 48 | value: Any = POLICY if policy is None else policy |
| 49 | for part in dotted.split("."): |
| 50 | value = value[part] |
| 51 | return value |
| 52 | |
| 53 | |
| 54 | # ------------------------------------------------------------------------------------------------------------------------ regular expressions |
| 55 | |
| 56 | def compile_anchored(pattern: str) -> re.Pattern[str]: |
| 57 | """Every pattern of the contracts is written ^...$ and means "the whole string": Python's `$` also matches before a final newline, so the |
| 58 | anchors are dropped and `fullmatch` is used (JavaScript and Go anchor strictly by themselves).""" |
| 59 | if not (pattern.startswith("^") and pattern.endswith("$")): |
| 60 | raise ValueError("contract patterns are anchored: " + pattern) |
| 61 | return re.compile(pattern[1:-1]) |
| 62 | |
| 63 | |
| 64 | LABEL_RE = compile_anchored(pol("identity.label_regex")) |
| 65 | FQDN_RE = compile_anchored(pol("identity.fqdn_regex")) |
| 66 | DEVICE_ID_RE = compile_anchored(pol("identity.device_id_regex")) |
| 67 | STREAM_ID_RE = compile_anchored(pol("identity.stream_id_regex")) |
| 68 | ATTACH_SECRET_RE = compile_anchored(pol("identity.attach_secret_regex")) |
| 69 | NONCE_RE = compile_anchored(pol("identity.nonce_regex")) |
| 70 | JTI_RE = compile_anchored(pol("identity.jti_regex")) |
| 71 | KID_RE = compile_anchored(pol("identity.kid_regex")) |
| 72 | |
| 73 | |
| 74 | def label_ok(value: Any) -> bool: |
| 75 | return isinstance(value, str) and LABEL_RE.fullmatch(value) is not None |
| 76 | |
| 77 | |
| 78 | def host_pattern(zone: str) -> re.Pattern[str]: |
| 79 | """The detector a device (or a node) builds for ITS OWN address in a zone: the policy's label pattern, one dot, the zone. The label part is |
| 80 | never typed again anywhere (the lint refuses a copy of it). Anchored with \\A and \\Z (not `$`, which also matches before a final newline), so |
| 81 | match, search and fullmatch all give the verdict the Go and TypeScript detectors give.""" |
| 82 | return re.compile(r"\A" + pol("identity.label_regex")[1:-1] + r"\." + re.escape(zone) + r"\Z") |
| 83 | |
| 84 | |
| 85 | def fqdn_ok(value: Any, zones: list[str] | None = None) -> bool: |
| 86 | """A device host: <label>.<zone>. With `zones`, the part after the label must be one of them (several zones are allowed by the contract).""" |
| 87 | if not isinstance(value, str) or len(value) > pol("identity.fqdn_max_length") or FQDN_RE.fullmatch(value) is None: |
| 88 | return False |
| 89 | return zones is None or value.split(".", 1)[1] in zones |
| 90 | |
| 91 | |
| 92 | # ------------------------------------------------------------------------------------------------------------------------ base64url, JSON |
| 93 | |
| 94 | def b64u_encode(raw: bytes) -> str: |
| 95 | return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") |
| 96 | |
| 97 | |
| 98 | _B64U = re.compile(r"[A-Za-z0-9_-]+") |
| 99 | |
| 100 | |
| 101 | def b64u_decode(text: str) -> bytes: |
| 102 | """Strict: the URL alphabet only, no padding, and the canonical encoding (re-encoding gives the same text).""" |
| 103 | if not isinstance(text, str) or not _B64U.fullmatch(text): |
| 104 | raise ValueError("base64url") |
| 105 | raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) |
| 106 | if b64u_encode(raw) != text: |
| 107 | raise ValueError("base64url") |
| 108 | return raw |
| 109 | |
| 110 | |
| 111 | def sha256_b64u(data: bytes | str) -> str: |
| 112 | return b64u_encode(hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).digest()) |
| 113 | |
| 114 | |
| 115 | def canonical_json(value: Any) -> str: |
| 116 | """The form of a JSON body on a wire line: keys sorted, no whitespace, ASCII only. Bodies hold integers, never floats.""" |
| 117 | return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) |
| 118 | |
| 119 | |
| 120 | def _no_floats(value: Any) -> Any: |
| 121 | if isinstance(value, float): |
| 122 | if value.is_integer(): |
| 123 | return int(value) |
| 124 | raise ValueError("float") |
| 125 | if isinstance(value, dict): |
| 126 | return {k: _no_floats(v) for k, v in value.items()} |
| 127 | if isinstance(value, list): |
| 128 | return [_no_floats(v) for v in value] |
| 129 | return value |
| 130 | |
| 131 | |
| 132 | def loads(text: str) -> Any: |
| 133 | """json.loads that refuses duplicate keys, NaN and Infinity (a body with a repeated key is ambiguous between implementations).""" |
| 134 | def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: |
| 135 | out: dict[str, Any] = {} |
| 136 | for key, item in items: |
| 137 | if key in out: |
| 138 | raise ValueError("duplicate key") |
| 139 | out[key] = item |
| 140 | return out |
| 141 | |
| 142 | def constant(_: str) -> Any: |
| 143 | raise ValueError("constant") |
| 144 | |
| 145 | return json.loads(text, object_pairs_hook=pairs, parse_constant=constant) |
| 146 | |
| 147 | |
| 148 | # ------------------------------------------------------------------------------------------------------------------------ JSON Schema subset |
| 149 | |
| 150 | def valid_ip(value: Any) -> bool: |
| 151 | """`format: ip`: a textual IPv4 or IPv6 address, lower case, no zone, no brackets, no spaces.""" |
| 152 | if not isinstance(value, str) or not value or value != value.lower() or any(c in value for c in "%[] \t\r\n/"): |
| 153 | return False |
| 154 | try: |
| 155 | ipaddress.ip_address(value) |
| 156 | except ValueError: |
| 157 | return False |
| 158 | return True |
| 159 | |
| 160 | |
| 161 | def _type_ok(kind: str, value: Any) -> bool: |
| 162 | if kind == "string": |
| 163 | return isinstance(value, str) |
| 164 | if kind == "boolean": |
| 165 | return isinstance(value, bool) |
| 166 | if kind == "null": |
| 167 | return value is None |
| 168 | if kind == "object": |
| 169 | return isinstance(value, dict) |
| 170 | if kind == "array": |
| 171 | return isinstance(value, list) |
| 172 | if kind == "integer": |
| 173 | return (isinstance(value, int) and not isinstance(value, bool)) or (isinstance(value, float) and value.is_integer()) |
| 174 | if kind == "number": |
| 175 | return isinstance(value, (int, float)) and not isinstance(value, bool) |
| 176 | return False |
| 177 | |
| 178 | |
| 179 | def _equal(a: Any, b: Any) -> bool: |
| 180 | """JSON equality: 1 is not true, 1 == 1.0, objects by content.""" |
| 181 | if isinstance(a, bool) or isinstance(b, bool): |
| 182 | return isinstance(a, bool) and isinstance(b, bool) and a == b |
| 183 | if isinstance(a, (int, float)) and isinstance(b, (int, float)): |
| 184 | return a == b |
| 185 | if isinstance(a, dict) and isinstance(b, dict): |
| 186 | return a.keys() == b.keys() and all(_equal(a[k], b[k]) for k in a) |
| 187 | if isinstance(a, list) and isinstance(b, list): |
| 188 | return len(a) == len(b) and all(_equal(x, y) for x, y in zip(a, b)) |
| 189 | return type(a) is type(b) and a == b |
| 190 | |
| 191 | |
| 192 | class Schemas: |
| 193 | """The schemas of contracts/schemas/ by file name, and a validator for the keywords they use: $ref, type, const, enum, pattern, format |
| 194 | (ip), minLength, maxLength, minimum, maximum, required, properties, additionalProperties (false), items, minItems, maxItems, uniqueItems, |
| 195 | oneOf. Anything else in a schema is ignored (titles, $comment, $id), so a keyword added later must be added to all three validators.""" |
| 196 | |
| 197 | def __init__(self, directory: Path | None = None): |
| 198 | self.docs: dict[str, Any] = {} |
| 199 | for path in sorted((directory or SCHEMA_DIR).glob("*.schema.json")): |
| 200 | self.docs[path.name] = json.loads(path.read_text(encoding="utf-8")) |
| 201 | self._patterns: dict[str, re.Pattern[str]] = {} |
| 202 | |
| 203 | def validate(self, name: str, instance: Any) -> list[str]: |
| 204 | errors: list[str] = [] |
| 205 | self._check(name, self.docs[name], instance, "", errors) |
| 206 | return errors |
| 207 | |
| 208 | def _resolve(self, doc: str, ref: str) -> tuple[str, Any]: |
| 209 | file, _, pointer = ref.partition("#") |
| 210 | target = file or doc |
| 211 | node: Any = self.docs[target] |
| 212 | if pointer: |
| 213 | for part in pointer.lstrip("/").split("/"): |
| 214 | node = node[part.replace("~1", "/").replace("~0", "~")] |
| 215 | return target, node |
| 216 | |
| 217 | def _pattern(self, text: str) -> re.Pattern[str]: |
| 218 | if text not in self._patterns: |
| 219 | self._patterns[text] = compile_anchored(text) |
| 220 | return self._patterns[text] |
| 221 | |
| 222 | def _check(self, doc: str, schema: Any, value: Any, path: str, errors: list[str]) -> None: |
| 223 | if "$ref" in schema: |
| 224 | target, node = self._resolve(doc, schema["$ref"]) |
| 225 | self._check(target, node, value, path, errors) |
| 226 | if "const" in schema and not _equal(value, schema["const"]): |
| 227 | errors.append(f"{path or '/'}: const") |
| 228 | if "enum" in schema and not any(_equal(value, item) for item in schema["enum"]): |
| 229 | errors.append(f"{path or '/'}: enum") |
| 230 | if "type" in schema: |
| 231 | kinds = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] |
| 232 | if not any(_type_ok(kind, value) for kind in kinds): |
| 233 | errors.append(f"{path or '/'}: type") |
| 234 | return |
| 235 | if "oneOf" in schema: |
| 236 | hits = 0 |
| 237 | for option in schema["oneOf"]: |
| 238 | trial: list[str] = [] |
| 239 | self._check(doc, option, value, path, trial) |
| 240 | hits += not trial |
| 241 | if hits != 1: |
| 242 | errors.append(f"{path or '/'}: oneOf") |
| 243 | if isinstance(value, str): |
| 244 | if "minLength" in schema and len(value) < schema["minLength"]: |
| 245 | errors.append(f"{path or '/'}: minLength") |
| 246 | if "maxLength" in schema and len(value) > schema["maxLength"]: |
| 247 | errors.append(f"{path or '/'}: maxLength") |
| 248 | if "pattern" in schema and self._pattern(schema["pattern"]).fullmatch(value) is None: |
| 249 | errors.append(f"{path or '/'}: pattern") |
| 250 | if schema.get("format") == "ip" and not valid_ip(value): |
| 251 | errors.append(f"{path or '/'}: format") |
| 252 | if isinstance(value, (int, float)) and not isinstance(value, bool): |
| 253 | if "minimum" in schema and value < schema["minimum"]: |
| 254 | errors.append(f"{path or '/'}: minimum") |
| 255 | if "maximum" in schema and value > schema["maximum"]: |
| 256 | errors.append(f"{path or '/'}: maximum") |
| 257 | if isinstance(value, dict): |
| 258 | for key in schema.get("required", []): |
| 259 | if key not in value: |
| 260 | errors.append(f"{path}/{key}: required") |
| 261 | props = schema.get("properties", {}) |
| 262 | for key, item in value.items(): |
| 263 | if key in props: |
| 264 | self._check(doc, props[key], item, f"{path}/{key}", errors) |
| 265 | elif schema.get("additionalProperties") is False: |
| 266 | errors.append(f"{path}/{key}: additionalProperties") |
| 267 | if isinstance(value, list): |
| 268 | if "minItems" in schema and len(value) < schema["minItems"]: |
| 269 | errors.append(f"{path or '/'}: minItems") |
| 270 | if "maxItems" in schema and len(value) > schema["maxItems"]: |
| 271 | errors.append(f"{path or '/'}: maxItems") |
| 272 | if schema.get("uniqueItems") and any(_equal(a, b) for i, a in enumerate(value) for b in value[i + 1:]): |
| 273 | errors.append(f"{path or '/'}: uniqueItems") |
| 274 | if "items" in schema: |
| 275 | for index, item in enumerate(value): |
| 276 | self._check(doc, schema["items"], item, f"{path}/{index}", errors) |
| 277 | |
| 278 | |
| 279 | # ------------------------------------------------------------------------------------------------------------------------ the wire grammar |
| 280 | |
| 281 | class ProtocolError(Exception): |
| 282 | """A line that is not valid. `code` is one of protocol.error_codes: the word the node answers with (ERR <code>).""" |
| 283 | |
| 284 | def __init__(self, code: str): |
| 285 | super().__init__(code) |
| 286 | self.code = code |
| 287 | |
| 288 | |
| 289 | GRAMMAR = json.loads((SCHEMA_DIR / "wire-v2.grammar.json").read_text(encoding="utf-8")) |
| 290 | SCHEMAS = Schemas() |
| 291 | |
| 292 | |
| 293 | def _field_ok(kind: str, token: str) -> bool: |
| 294 | spec = GRAMMAR["types"][kind] |
| 295 | if "max_bytes_from" in spec and len(token) > pol(spec["max_bytes_from"]): |
| 296 | return False |
| 297 | if "pattern" in spec and compile_anchored(spec["pattern"]).fullmatch(token) is None: |
| 298 | return False |
| 299 | if spec.get("format") == "ip" and not valid_ip(token): |
| 300 | return False |
| 301 | if "enum_from" in spec and token not in pol(spec["enum_from"]): |
| 302 | return False |
| 303 | return True |
| 304 | |
| 305 | |
| 306 | def parse_line(line: str, direction: str) -> dict[str, Any]: |
| 307 | """Parse one protocol line (the text before the LF) travelling in `direction` ("d2n" or "n2d"). Returns {"verb": ..., <field>: ...}; HINT |
| 308 | gives "addresses", OK gives "body". Raises ProtocolError.""" |
| 309 | if not isinstance(line, str) or any(not 0x20 <= ord(c) <= 0x7E for c in line): |
| 310 | raise ProtocolError("bad_command") |
| 311 | verb, space, rest = line.partition(" ") |
| 312 | spec = GRAMMAR["verbs"].get(verb) |
| 313 | cap = pol(spec["max"]) if spec and "max" in spec else pol("protocol.line_max_bytes") |
| 314 | if len(line) + 1 > cap: |
| 315 | raise ProtocolError("line_too_long") |
| 316 | if spec is None or spec["dir"] not in (direction, "both") or line == "" or (space and rest == ""): |
| 317 | raise ProtocolError("bad_command") |
| 318 | out: dict[str, Any] = {"verb": verb} |
| 319 | if "json" in spec: |
| 320 | if not space: |
| 321 | raise ProtocolError("bad_command") |
| 322 | try: |
| 323 | body = _no_floats(loads(rest)) |
| 324 | except ValueError: |
| 325 | raise ProtocolError("bad_command") from None |
| 326 | if SCHEMAS.validate(spec["json"], body): |
| 327 | raise ProtocolError("bad_command") |
| 328 | out["body"] = body |
| 329 | return out |
| 330 | tokens = rest.split(" ") if space else [] |
| 331 | if any(token == "" for token in tokens): |
| 332 | raise ProtocolError("bad_command") |
| 333 | if "repeat" in spec: |
| 334 | rule = spec["repeat"] |
| 335 | if not rule["min"] <= len(tokens) <= pol(rule["max_from"]) or not all(_field_ok(rule["type"], t) for t in tokens): |
| 336 | raise ProtocolError("bad_command") |
| 337 | out["addresses"] = tokens |
| 338 | return out |
| 339 | fields = spec.get("fields", []) |
| 340 | optional = spec.get("optional", []) |
| 341 | if not len(fields) <= len(tokens) <= len(fields) + len(optional): |
| 342 | raise ProtocolError("bad_command") |
| 343 | for rule, token in zip(fields + optional, tokens): |
| 344 | if "equals" in rule and token != rule["equals"]: |
| 345 | raise ProtocolError(rule["error_if_number"] if _field_ok(rule["type"], token) else "bad_command") |
| 346 | if not _field_ok(rule["type"], token): |
| 347 | raise ProtocolError(rule.get("error", "bad_command")) |
| 348 | out[rule["name"]] = token |
| 349 | return out |
| 350 | |
| 351 | |
| 352 | def format_line(verb: str, **fields: Any) -> str: |
| 353 | """The canonical line for a message (the inverse of parse_line, which it is checked against): the field values in grammar order; OK takes |
| 354 | `body`, HINT takes `addresses`. Raises ProtocolError when the result would not parse.""" |
| 355 | spec = GRAMMAR["verbs"][verb] |
| 356 | if "json" in spec: |
| 357 | line = f"{verb} {canonical_json(fields['body'])}" |
| 358 | elif "repeat" in spec: |
| 359 | line = " ".join([verb, *fields["addresses"]]) |
| 360 | else: |
| 361 | names = [rule["name"] for rule in spec.get("fields", []) + spec.get("optional", []) if rule["name"] in fields] |
| 362 | line = " ".join([verb, *(str(fields[name]) for name in names)]) |
| 363 | direction = "d2n" if spec["dir"] == "both" else spec["dir"] |
| 364 | parse_line(line, direction) |
| 365 | return line |
| 366 | |
| 367 | |
| 368 | # ------------------------------------------------------------------------------------------------------------------------ tokens |
| 369 | |
| 370 | @dataclass |
| 371 | class Result: |
| 372 | ok: bool |
| 373 | error: str | None = None |
| 374 | claims: dict[str, Any] = field(default_factory=dict) |
| 375 | jti: str | None = None |
| 376 | |
| 377 | @staticmethod |
| 378 | def fail(code: str) -> "Result": |
| 379 | return Result(False, code) |
| 380 | |
| 381 | |
| 382 | class Reject(Exception): |
| 383 | def __init__(self, code: str): |
| 384 | super().__init__(code) |
| 385 | self.code = code |
| 386 | |
| 387 | |
| 388 | def _jws_parts(token: Any, max_bytes: int) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]: |
| 389 | try: |
| 390 | if not isinstance(token, str) or not token.isascii() or len(token) > max_bytes: |
| 391 | raise ValueError() |
| 392 | parts = token.split(".") |
| 393 | if len(parts) != 3: |
| 394 | raise ValueError() |
| 395 | header = loads(b64u_decode(parts[0]).decode("utf-8")) |
| 396 | claims = loads(b64u_decode(parts[1]).decode("utf-8")) |
| 397 | signature = b64u_decode(parts[2]) if parts[2] else b"" |
| 398 | if not isinstance(header, dict) or not isinstance(claims, dict): |
| 399 | raise ValueError() |
| 400 | if any(name in header for name in ("crit", "b64", "jku", "x5u", "x5c")): |
| 401 | raise ValueError() |
| 402 | claims = _no_floats(claims) |
| 403 | except (ValueError, UnicodeError): |
| 404 | raise Reject("malformed_token") from None |
| 405 | return header, claims, signature, f"{parts[0]}.{parts[1]}".encode("ascii") |
| 406 | |
| 407 | |
| 408 | def _ed25519_ok(x: str, signature: bytes, signing_input: bytes) -> bool: |
| 409 | """Ed25519 verification; a signature that is not 64 bytes is simply not valid.""" |
| 410 | if len(signature) != 64: |
| 411 | return False |
| 412 | try: |
| 413 | Ed25519PublicKey.from_public_bytes(b64u_decode(x)).verify(signature, signing_input) |
| 414 | return True |
| 415 | except (InvalidSignature, ValueError): |
| 416 | return False |
| 417 | |
| 418 | |
| 419 | def _is_int(value: Any) -> bool: |
| 420 | return isinstance(value, int) and not isinstance(value, bool) |
| 421 | |
| 422 | |
| 423 | def _jwk_x(value: Any) -> str: |
| 424 | """The `x` of a public Ed25519 JWK (kty OKP, crv Ed25519, no private part), or a Reject.""" |
| 425 | if (not isinstance(value, dict) or value.get("kty") != "OKP" or value.get("crv") != "Ed25519" or "d" in value |
| 426 | or not isinstance(value.get("x"), str) or len(value["x"]) != 43): |
| 427 | raise Reject("malformed_token") |
| 428 | try: |
| 429 | if len(b64u_decode(value["x"])) != 32: |
| 430 | raise ValueError() |
| 431 | except ValueError: |
| 432 | raise Reject("malformed_token") from None |
| 433 | return value["x"] |
| 434 | |
| 435 | |
| 436 | def parse_jwks(jwks: Any) -> dict[str, str]: |
| 437 | """kid -> x of the control plane's keys (schemas/jwks.schema.json, at most policy.jwks.max_keys, no repeated kid), or Reject("bad_jwks").""" |
| 438 | if SCHEMAS.validate("jwks.schema.json", jwks): |
| 439 | raise Reject("bad_jwks") |
| 440 | keys: dict[str, str] = {} |
| 441 | for key in jwks["keys"]: |
| 442 | if key["kid"] in keys: |
| 443 | raise Reject("bad_jwks") |
| 444 | keys[key["kid"]] = key["x"] |
| 445 | return keys |
| 446 | |
| 447 | |
| 448 | def parse_node_keys(doc: Any) -> dict[str, tuple[str, str]]: |
| 449 | """kid -> (node id, x) of TUNNEL_NODE_KEYS, or Reject("bad_node_keys").""" |
| 450 | if SCHEMAS.validate("node-keys.schema.json", doc): |
| 451 | raise Reject("bad_node_keys") |
| 452 | keys: dict[str, tuple[str, str]] = {} |
| 453 | for key in doc["keys"]: |
| 454 | if key["kid"] in keys: |
| 455 | raise Reject("bad_node_keys") |
| 456 | keys[key["kid"]] = (key["node"], key["x"]) |
| 457 | return keys |
| 458 | |
| 459 | |
| 460 | def _signed_by_jwks(token: Any, keys: dict[str, str], typ: str) -> tuple[dict[str, Any], dict[str, Any]]: |
| 461 | header, claims, signature, signing_input = _jws_parts(token, pol("protocol.statement_line_max_bytes")) |
| 462 | if header.get("alg") != "EdDSA": |
| 463 | raise Reject("bad_alg") |
| 464 | kid = header.get("kid") |
| 465 | if not isinstance(kid, str) or KID_RE.fullmatch(kid) is None or kid not in keys: |
| 466 | raise Reject("unknown_kid") |
| 467 | if not _ed25519_ok(keys[kid], signature, signing_input): |
| 468 | raise Reject("bad_signature") |
| 469 | if header.get("typ") != typ or claims.get("typ") != typ: |
| 470 | raise Reject("wrong_type") |
| 471 | return header, claims |
| 472 | |
| 473 | |
| 474 | def _claim_ok(claims: dict[str, Any], schema: str, ignore: tuple[str, ...] = ()) -> bool: |
| 475 | """Shape of the claims by their schema, without the keywords checked earlier under their own error word.""" |
| 476 | errors = SCHEMAS.validate(schema, claims) |
| 477 | return not [e for e in errors if not any(e.startswith(f"/{name}:") for name in ignore)] |
| 478 | |
| 479 | |
| 480 | def verify_session(token: Any, *, now: int, audience: str, keys: dict[str, str], skew: int) -> Result: |
| 481 | """A device session (typ device-session, ver 2) as the node must accept it at HELLO and RENEW. Order of failure: malformed_token, bad_alg, |
| 482 | unknown_kid, bad_signature, wrong_type, wrong_issuer, wrong_audience, bad_claims, bad_lifetime, not_yet_valid, expired.""" |
| 483 | try: |
| 484 | if not isinstance(token, str) or len(token) > pol("protocol.jwt_max_bytes"): |
| 485 | raise Reject("malformed_token") |
| 486 | _, claims = _signed_by_jwks(token, keys, "device-session") |
| 487 | if claims.get("ver") != 2 or isinstance(claims.get("ver"), bool): |
| 488 | raise Reject("wrong_type") |
| 489 | if claims.get("iss") != pol("identity.issuer"): |
| 490 | raise Reject("wrong_issuer") |
| 491 | if claims.get("aud") != audience: |
| 492 | raise Reject("wrong_audience") |
| 493 | if not _claim_ok(claims, "session-claims.schema.json", ("aud",)): |
| 494 | raise Reject("bad_claims") |
| 495 | if claims["exp"] - claims["iat"] != pol("authorization.session_ttl_s"): |
| 496 | raise Reject("bad_lifetime") |
| 497 | if claims["iat"] > now + skew: |
| 498 | raise Reject("not_yet_valid") |
| 499 | if claims["exp"] <= now: |
| 500 | raise Reject("expired") |
| 501 | return Result(True, claims=claims, jti=claims["jti"]) |
| 502 | except Reject as reject: |
| 503 | return Result.fail(reject.code) |
| 504 | |
| 505 | |
| 506 | def verify_proof(proof: Any, *, now: int, session_token: str, session_claims: dict[str, Any], nonce: str, htu: str, skew: int, |
| 507 | seen: set[str] | frozenset[str]) -> Result: |
| 508 | """The DPoP proof of PROOF. Order of failure: malformed_token, bad_alg, wrong_type, wrong_key, bad_signature, wrong_subject, |
| 509 | wrong_audience, wrong_htm, wrong_htu, wrong_ath, wrong_nonce, bad_claims, not_yet_valid, stale_proof, replayed_jti.""" |
| 510 | try: |
| 511 | if not isinstance(proof, str) or len(proof) > pol("protocol.jwt_max_bytes"): |
| 512 | raise Reject("malformed_token") |
| 513 | header, claims, signature, signing_input = _jws_parts(proof, pol("protocol.jwt_max_bytes")) |
| 514 | if header.get("alg") != "EdDSA": |
| 515 | raise Reject("bad_alg") |
| 516 | if header.get("typ") != "dpop+jwt": |
| 517 | raise Reject("wrong_type") |
| 518 | x = _jwk_x(header.get("jwk")) |
| 519 | if x != session_claims["cnf"]["jwk"]["x"]: |
| 520 | raise Reject("wrong_key") |
| 521 | if not _ed25519_ok(x, signature, signing_input): |
| 522 | raise Reject("bad_signature") |
| 523 | if claims.get("sub") != session_claims["sub"]: |
| 524 | raise Reject("wrong_subject") |
| 525 | if claims.get("aud") != pol("identity.aud_tunnel"): |
| 526 | raise Reject("wrong_audience") |
| 527 | if claims.get("htm") != pol("protocol.proof_htm"): |
| 528 | raise Reject("wrong_htm") |
| 529 | if claims.get("htu") != htu: |
| 530 | raise Reject("wrong_htu") |
| 531 | if claims.get("ath") != sha256_b64u(session_token): |
| 532 | raise Reject("wrong_ath") |
| 533 | if claims.get("nonce") != nonce: |
| 534 | raise Reject("wrong_nonce") |
| 535 | if not _claim_ok(claims, "proof-claims.schema.json", ("htu",)): |
| 536 | raise Reject("bad_claims") |
| 537 | if claims["iat"] > now + skew: |
| 538 | raise Reject("not_yet_valid") |
| 539 | if claims["iat"] + pol("authorization.proof_ttl_s") <= now: |
| 540 | raise Reject("stale_proof") |
| 541 | if claims["jti"] in seen: |
| 542 | raise Reject("replayed_jti") |
| 543 | return Result(True, claims=claims, jti=claims["jti"]) |
| 544 | except Reject as reject: |
| 545 | return Result.fail(reject.code) |
| 546 | |
| 547 | |
| 548 | def verify_node_assertion(token: Any, *, now: int, keys: dict[str, tuple[str, str]], method: str, url: str, body: bytes, skew: int, |
| 549 | seen: set[str] | frozenset[str]) -> Result: |
| 550 | """A node's request authentication (header x-all-node-assertion). Order of failure: malformed_token, bad_alg, unknown_kid, bad_signature, |
| 551 | wrong_type, wrong_audience, wrong_subject, wrong_htm, wrong_htu, bad_claims, bad_lifetime, not_yet_valid, expired, wrong_body_hash, |
| 552 | replayed_jti.""" |
| 553 | try: |
| 554 | header, claims, signature, signing_input = _jws_parts(token, pol("protocol.jwt_max_bytes")) |
| 555 | if header.get("alg") != "EdDSA": |
| 556 | raise Reject("bad_alg") |
| 557 | kid = header.get("kid") |
| 558 | if not isinstance(kid, str) or KID_RE.fullmatch(kid) is None or kid not in keys: |
| 559 | raise Reject("unknown_kid") |
| 560 | node, x = keys[kid] |
| 561 | if not _ed25519_ok(x, signature, signing_input): |
| 562 | raise Reject("bad_signature") |
| 563 | if header.get("typ") != "node-assertion" or claims.get("typ") != "node-assertion" or claims.get("ver") != 1: |
| 564 | raise Reject("wrong_type") |
| 565 | if claims.get("aud") != pol("identity.aud_node_assertion"): |
| 566 | raise Reject("wrong_audience") |
| 567 | if claims.get("sub") != node: |
| 568 | raise Reject("wrong_subject") |
| 569 | if claims.get("htm") != method: |
| 570 | raise Reject("wrong_htm") |
| 571 | if claims.get("htu") != url: |
| 572 | raise Reject("wrong_htu") |
| 573 | if not _claim_ok(claims, "node-assertion-claims.schema.json", ("htm", "htu", "aud", "sub")): |
| 574 | raise Reject("bad_claims") |
| 575 | if claims["exp"] - claims["iat"] != pol("authorization.node_assertion_ttl_s"): |
| 576 | raise Reject("bad_lifetime") |
| 577 | if claims["iat"] > now + skew: |
| 578 | raise Reject("not_yet_valid") |
| 579 | if claims["exp"] <= now: |
| 580 | raise Reject("expired") |
| 581 | if claims["bh"] != sha256_b64u(body): |
| 582 | raise Reject("wrong_body_hash") |
| 583 | if claims["jti"] in seen: |
| 584 | raise Reject("replayed_jti") |
| 585 | return Result(True, claims=claims, jti=claims["jti"]) |
| 586 | except Reject as reject: |
| 587 | return Result.fail(reject.code) |
| 588 | |
| 589 | |
| 590 | def verify_statement(token: Any, *, now: int, keys: dict[str, str], device_id: str, last_seq: int) -> Result: |
| 591 | """The control plane's signed access statement as the device must accept it (STATEMENT, authorize). Order of failure: malformed_token, |
| 592 | bad_alg, unknown_kid, bad_signature, wrong_type, wrong_issuer, wrong_audience, wrong_subject, bad_claims, bad_lifetime, not_yet_valid, |
| 593 | stale_seq. A statement that has merely EXPIRED still verifies (a stored paywall keeps gating): the caller compares exp with its own clock. |
| 594 | last_seq is the highest seq accepted so far (0 for none); the same seq again is accepted (idempotent), a lower one is stale_seq.""" |
| 595 | try: |
| 596 | _, claims = _signed_by_jwks(token, keys, "device-access") |
| 597 | if claims.get("ver") != 1 or isinstance(claims.get("ver"), bool): |
| 598 | raise Reject("wrong_type") |
| 599 | if claims.get("iss") != pol("identity.issuer"): |
| 600 | raise Reject("wrong_issuer") |
| 601 | if claims.get("aud") != pol("identity.aud_access"): |
| 602 | raise Reject("wrong_audience") |
| 603 | if claims.get("sub") != device_id: |
| 604 | raise Reject("wrong_subject") |
| 605 | if (not _claim_ok(claims, "statement-claims.schema.json", ("aud", "sub")) or ("paywall" in claims) != (claims["mode"] == "paywall") |
| 606 | or ("paused" in claims and "resumed" in claims)): |
| 607 | raise Reject("bad_claims") |
| 608 | ttl = claims["exp"] - claims["iat"] |
| 609 | if not 0 < ttl <= pol("authorization.access_ttl_paywall_s" if claims["mode"] == "paywall" else "authorization.access_ttl_paid_s"): |
| 610 | raise Reject("bad_lifetime") |
| 611 | if claims["iat"] > now + pol("authorization.access_future_slack_s"): |
| 612 | raise Reject("not_yet_valid") |
| 613 | if claims["seq"] < last_seq: |
| 614 | raise Reject("stale_seq") |
| 615 | return Result(True, claims=claims, jti=claims["jti"]) |
| 616 | except Reject as reject: |
| 617 | return Result.fail(reject.code) |
| 618 | |
| 619 | |
| 620 | # ------------------------------------------------------------------------------------------------------------------------ the feed |
| 621 | |
| 622 | def check_feed(body: Any, since: int) -> list[str]: |
| 623 | """Schema plus the rules a schema cannot say: events strictly increasing, all after `since`, epoch not below the last event, and the two |
| 624 | global events (kill, kill_clear) carry no device, every other event carries both device and label.""" |
| 625 | errors = SCHEMAS.validate("feed-response.schema.json", body) |
| 626 | if errors: |
| 627 | return errors |
| 628 | previous = since |
| 629 | for index, event in enumerate(body["events"]): |
| 630 | if event["epoch"] <= previous: |
| 631 | errors.append(f"/events/{index}: order") |
| 632 | previous = max(previous, event["epoch"]) |
| 633 | is_global = event["type"] in ("kill", "kill_clear") |
| 634 | if is_global != (event["device_id"] is None and event["label"] is None): |
| 635 | errors.append(f"/events/{index}: scope") |
| 636 | if (event["device_id"] is None) != (event["label"] is None): |
| 637 | errors.append(f"/events/{index}: scope") |
| 638 | if body["epoch"] < previous: |
| 639 | errors.append("/epoch: behind events") |
| 640 | return errors |
| 641 | |
| 642 | |
| 643 | # ------------------------------------------------------------------------------------------------------------------------ surface identity proof |
| 644 | |
| 645 | def _surface_key(secret: str) -> bytes: |
| 646 | raw = b64u_decode(secret) |
| 647 | if len(raw) != 32: |
| 648 | raise ValueError("secret") |
| 649 | return raw |
| 650 | |
| 651 | |
| 652 | def surface_mac(secret: str, nonce: str) -> str: |
| 653 | """The MAC the terminator puts on its challenge: only a holder of the per-start secret (the agent) gets an answer; anybody else gets a 404.""" |
| 654 | return b64u_encode(hmac.new(_surface_key(secret), b"all-surface-challenge-v1\n" + nonce.encode("ascii"), hashlib.sha256).digest()) |
| 655 | |
| 656 | |
| 657 | def surface_proof(secret: str, nonce: str, pid: int, port: int) -> str: |
| 658 | """What the listener answers: it proves it holds the secret of the process surfaces.json names, for this nonce, pid and port.""" |
| 659 | message = b"all-surface-proof-v1\n" + nonce.encode("ascii") + b"\n" + str(pid).encode() + b"\n" + str(port).encode() |
| 660 | return b64u_encode(hmac.new(_surface_key(secret), message, hashlib.sha256).digest()) |
| 661 | |
| 662 | |
| 663 | def verify_surface_challenge(secret: str, nonce: str, mac: Any) -> Result: |
| 664 | try: |
| 665 | if not isinstance(nonce, str) or NONCE_RE.fullmatch(nonce) is None or not isinstance(mac, str): |
| 666 | return Result.fail("bad_challenge") |
| 667 | return Result(True) if hmac.compare_digest(surface_mac(secret, nonce), mac) else Result.fail("bad_challenge") |
| 668 | except ValueError: |
| 669 | return Result.fail("bad_challenge") |
| 670 | |
| 671 | |
| 672 | def verify_surface_proof(secret: str, nonce: str, pid: int, port: int, proof: Any) -> Result: |
| 673 | try: |
| 674 | if not isinstance(proof, str) or not isinstance(nonce, str) or NONCE_RE.fullmatch(nonce) is None: |
| 675 | return Result.fail("bad_proof") |
| 676 | return Result(True) if hmac.compare_digest(surface_proof(secret, nonce, pid, port), proof) else Result.fail("bad_proof") |
| 677 | except ValueError: |
| 678 | return Result.fail("bad_proof") |
| 679 | |
| 680 | |
| 681 | def identity_request(host: str, port: int, nonce: str, mac: str) -> str: |
| 682 | """The exact request head the terminator sends on the connection it is about to hand to ChatGPT (before the first ChatGPT byte).""" |
| 683 | return (f"GET {pol('surface.identity_path')} HTTP/1.1\r\nHost: {host}:{port}\r\n{pol('surface.identity_challenge_header')}: {nonce}\r\n" |
| 684 | f"{pol('surface.identity_mac_header')}: {mac}\r\nConnection: keep-alive\r\n\r\n") |
| 685 | |
| 686 | |
| 687 | # ------------------------------------------------------------------------------------------------------------------------ node behaviour |
| 688 | |
| 689 | @dataclass |
| 690 | class _Conn: |
| 691 | phase: str = "new" # new, hello, ready, closed |
| 692 | session: str | None = None |
| 693 | claims: dict[str, Any] | None = None |
| 694 | session_ok: bool = False |
| 695 | kind: str = "hello" |
| 696 | nonce: str | None = None |
| 697 | proof: str = "none" # none, ok, replayed, invalid |
| 698 | authorized: bool = False |
| 699 | reply: dict[str, Any] | None = None |
| 700 | host: str | None = None |
| 701 | status: str | None = None |
| 702 | valid_until: int = 0 |
| 703 | superseded: bool = False |
| 704 | sub: str | None = None |
| 705 | |
| 706 | |
| 707 | def _violation(code: str) -> Result: |
| 708 | return Result(False, code) |
| 709 | |
| 710 | |
| 711 | def check_transcript(doc: dict[str, Any]) -> Result: |
| 712 | """Does the recorded behaviour of ONE node obey the contract? `doc` is {"ctx": {...}, "events": [...]} . |
| 713 | Returns ok, or the first violation word. The rules, each with its word: |
| 714 | handshake challenge_for_invalid_session, ok_without_proof, ok_with_invalid_proof, replayed_proof_accepted, ok_without_authorize, |
| 715 | renew_without_authorize, bad_authorize_request, host_not_from_control_plane, ok_mismatch_control_plane, bad_line |
| 716 | streams open_before_ok, open_when_paused, open_after_valid_until, open_without_client_hello, open_wrong_host, open_wrong_client, |
| 717 | duplicate_stream |
| 718 | attach attach_unknown_accepted, attach_wrong_secret_accepted, attach_secret_reused, attach_after_timeout, attach_refused_good_secret |
| 719 | lifetime no_close_at_valid_until, replaced_not_closed (both are judged at the `end` event: a recording without one is transcript_without_end) |
| 720 | """ |
| 721 | ctx = doc["ctx"] |
| 722 | keys = parse_jwks(ctx["jwks"]) |
| 723 | skew = ctx["skew"] |
| 724 | conns: dict[str, _Conn] = {} |
| 725 | seen: set[str] = set() |
| 726 | streams: dict[str, dict[str, Any]] = {} |
| 727 | secrets_used: set[str] = set() |
| 728 | hellos: dict[str, list[tuple[str, str]]] = {} # per host: the (peer_ip, class) of each complete ClientHello still waiting for its OPEN |
| 729 | ended = False |
| 730 | now = 0 |
| 731 | |
| 732 | for event in doc["events"]: |
| 733 | now = event["t"] |
| 734 | kind = event["ev"] |
| 735 | conn = conns.setdefault(event["conn"], _Conn()) if "conn" in event and kind in ("recv", "send", "cp", "cp_reply") else None |
| 736 | if kind == "client_hello": |
| 737 | if event["complete"]: |
| 738 | hellos.setdefault(event["sni"], []).append((event["peer_ip"], event["class"])) |
| 739 | elif kind == "recv": |
| 740 | assert conn is not None |
| 741 | try: |
| 742 | message = parse_line(event["line"], "d2n") |
| 743 | except ProtocolError: |
| 744 | continue |
| 745 | verb = message["verb"] |
| 746 | if verb in ("HELLO", "RENEW") and (conn.phase == "new" if verb == "HELLO" else conn.phase == "ready"): |
| 747 | result = verify_session(message["session"], now=now, audience=pol("identity.aud_tunnel"), keys=keys, skew=skew) |
| 748 | conn.session_ok = result.ok and (verb == "HELLO" or result.claims["sub"] == conn.sub) |
| 749 | conn.session = message["session"] |
| 750 | conn.claims = result.claims if result.ok else None |
| 751 | conn.kind = "hello" if verb == "HELLO" else "renew" |
| 752 | conn.nonce = None |
| 753 | conn.proof, conn.authorized, conn.reply = "none", False, None |
| 754 | if verb == "HELLO": |
| 755 | conn.phase = "hello" |
| 756 | elif verb == "PROOF" and conn.session_ok and conn.nonce is not None: |
| 757 | result = verify_proof(message["proof"], now=now, session_token=conn.session, session_claims=conn.claims, nonce=conn.nonce, |
| 758 | htu=ctx["node_url"], skew=skew, seen=seen) |
| 759 | conn.proof = "ok" if result.ok else ("replayed" if result.error == "replayed_jti" else "invalid") |
| 760 | conn.authorized, conn.reply = False, None |
| 761 | if result.ok and result.jti is not None: |
| 762 | seen.add(result.jti) |
| 763 | elif kind == "cp": |
| 764 | assert conn is not None and event["call"] == "authorize" |
| 765 | body = event["body"] |
| 766 | if (SCHEMAS.validate("authorize-request.schema.json", body) or conn.claims is None or body["device_id"] != conn.claims["sub"] |
| 767 | or body["session_jti"] != conn.claims["jti"] or body["reason"] != conn.kind or body["node"] != ctx["node_id"]): |
| 768 | return _violation("bad_authorize_request") |
| 769 | if conn.proof == "ok": |
| 770 | conn.authorized = True |
| 771 | elif kind == "cp_reply": |
| 772 | assert conn is not None |
| 773 | conn.reply = event if conn.authorized else None |
| 774 | elif kind == "send": |
| 775 | assert conn is not None |
| 776 | try: |
| 777 | message = parse_line(event["line"], "n2d") |
| 778 | except ProtocolError: |
| 779 | return _violation("bad_line") |
| 780 | verb = message["verb"] |
| 781 | if verb == "CHALLENGE": |
| 782 | if not conn.session_ok or conn.phase not in ("hello", "ready"): |
| 783 | return _violation("challenge_for_invalid_session") |
| 784 | conn.nonce = message["nonce"] |
| 785 | elif verb == "OK": |
| 786 | if event["line"] != "OK " + canonical_json(message["body"]): |
| 787 | return _violation("bad_line") # the node sends the canonical form (the device's parser is lenient: see docs section 2) |
| 788 | if conn.proof == "none": |
| 789 | return _violation("ok_without_proof") |
| 790 | if conn.proof == "replayed": |
| 791 | return _violation("replayed_proof_accepted") |
| 792 | if conn.proof != "ok": |
| 793 | return _violation("ok_with_invalid_proof") |
| 794 | reply = conn.reply |
| 795 | if not conn.authorized or reply is None or reply["status"] != 200: |
| 796 | return _violation("renew_without_authorize" if conn.kind == "renew" else "ok_without_authorize") |
| 797 | body = message["body"] |
| 798 | answer = reply["body"] |
| 799 | if body["host"] != answer["host"]: |
| 800 | return _violation("host_not_from_control_plane") |
| 801 | if (body["status"] != answer["status"] or body["valid_until"] != answer["valid_until"] or body["policy"] != answer["policy"] |
| 802 | or body["node"] != ctx["node_id"]): |
| 803 | return _violation("ok_mismatch_control_plane") |
| 804 | conn.phase, conn.host, conn.status, conn.valid_until, conn.sub = "ready", body["host"], body["status"], body["valid_until"], conn.claims["sub"] |
| 805 | conn.proof, conn.authorized, conn.reply = "none", False, None |
| 806 | for other_name, other in conns.items(): |
| 807 | if other is not conn and other.phase == "ready" and other.sub == conn.sub: |
| 808 | other.superseded = True |
| 809 | elif verb == "OPEN": |
| 810 | if conn.phase != "ready" or conn.status is None: |
| 811 | return _violation("open_before_ok") |
| 812 | if conn.status == "paused": |
| 813 | return _violation("open_when_paused") |
| 814 | if now >= conn.valid_until: |
| 815 | return _violation("open_after_valid_until") |
| 816 | pending = hellos.get(conn.host or "", []) |
| 817 | if not pending: |
| 818 | return _violation("open_wrong_host" if any(hellos.values()) else "open_without_client_hello") |
| 819 | if (message["peer_ip"], message["class"]) not in pending: |
| 820 | return _violation("open_wrong_client") |
| 821 | if message["stream_id"] in streams or message["secret"] in secrets_used: |
| 822 | return _violation("duplicate_stream") |
| 823 | pending.remove((message["peer_ip"], message["class"])) |
| 824 | streams[message["stream_id"]] = {"secret": message["secret"], "t": now, "consumed": False} |
| 825 | secrets_used.add(message["secret"]) |
| 826 | elif verb == "CLOSE": |
| 827 | conn.phase = "closed" |
| 828 | elif kind == "attach": |
| 829 | try: |
| 830 | message = parse_line(event["line"], "d2n") |
| 831 | except ProtocolError: |
| 832 | continue |
| 833 | known = streams.get(message["stream_id"]) |
| 834 | good = known is not None and hmac.compare_digest(known["secret"], message["secret"]) |
| 835 | fresh = good and not known["consumed"] and now - known["t"] <= pol("protocol.attach_timeout_s") |
| 836 | if event["result"] == "ok": |
| 837 | if known is None: |
| 838 | return _violation("attach_unknown_accepted") |
| 839 | if not good: |
| 840 | return _violation("attach_wrong_secret_accepted") |
| 841 | if known["consumed"]: |
| 842 | return _violation("attach_secret_reused") |
| 843 | if not fresh: |
| 844 | return _violation("attach_after_timeout") |
| 845 | known["consumed"] = True |
| 846 | elif fresh: |
| 847 | return _violation("attach_refused_good_secret") |
| 848 | elif kind == "end": |
| 849 | ended = True |
| 850 | for other in conns.values(): |
| 851 | if other.phase == "ready" and other.superseded: |
| 852 | return _violation("replaced_not_closed") |
| 853 | if other.phase == "ready" and now > other.valid_until: |
| 854 | return _violation("no_close_at_valid_until") |
| 855 | return Result(True) if ended else _violation("transcript_without_end") |