ALL

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

SHA-256: 73f61a04183c0b6703085cb847361c1448e5172384d0d1b197bcb498f54a635d
855 lines · 41881 bytes · View on GitHub

1"""ALL 2.0 contracts, Python reference verifier (the contract).
2
3The spec is `contracts/policy.json` (every constant), `contracts/schemas/` (shapes, line grammar) and `contracts/vectors/` (golden inputs and the
4verdict 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
6every vector (the contract test suite, tunnel/contracts_test.go, the Worker contract test). It is a reference for tests and
7for 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
9What 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...)
16Pure functions: the clock, the replay set and the key sets are arguments, never globals.
17"""
18from __future__ import annotations
19
20import base64
21import hashlib
22import hmac
23import ipaddress
24import json
25import re
26from dataclasses import dataclass, field
27from pathlib import Path
28from typing import Any
29
30from cryptography.exceptions import InvalidSignature
31from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
32
33ROOT = Path(__file__).resolve().parent
34POLICY_PATH = ROOT / "policy.json"
35SCHEMA_DIR = ROOT / "schemas"
36VECTOR_DIR = ROOT / "vectors"
37
38
39def load_policy(path: Path | None = None) -> dict[str, Any]:
40 return json.loads((path or POLICY_PATH).read_text(encoding="utf-8"))
41
42
43POLICY = load_policy()
44
45
46def 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
56def 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
64LABEL_RE = compile_anchored(pol("identity.label_regex"))
65FQDN_RE = compile_anchored(pol("identity.fqdn_regex"))
66DEVICE_ID_RE = compile_anchored(pol("identity.device_id_regex"))
67STREAM_ID_RE = compile_anchored(pol("identity.stream_id_regex"))
68ATTACH_SECRET_RE = compile_anchored(pol("identity.attach_secret_regex"))
69NONCE_RE = compile_anchored(pol("identity.nonce_regex"))
70JTI_RE = compile_anchored(pol("identity.jti_regex"))
71KID_RE = compile_anchored(pol("identity.kid_regex"))
72
73
74def label_ok(value: Any) -> bool:
75 return isinstance(value, str) and LABEL_RE.fullmatch(value) is not None
76
77
78def 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
85def 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
94def 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
101def 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
111def 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
115def 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
120def _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
132def 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
150def 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
161def _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
179def _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
192class 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
281class 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
289GRAMMAR = json.loads((SCHEMA_DIR / "wire-v2.grammar.json").read_text(encoding="utf-8"))
290SCHEMAS = Schemas()
291
292
293def _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
306def 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
352def 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
371class 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
382class Reject(Exception):
383 def __init__(self, code: str):
384 super().__init__(code)
385 self.code = code
386
387
388def _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
408def _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
419def _is_int(value: Any) -> bool:
420 return isinstance(value, int) and not isinstance(value, bool)
421
422
423def _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
436def 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
448def 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
460def _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
474def _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
480def 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
506def 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
548def 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
590def 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
622def 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
645def _surface_key(secret: str) -> bytes:
646 raw = b64u_decode(secret)
647 if len(raw) != 32:
648 raise ValueError("secret")
649 return raw
650
651
652def 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
657def 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
663def 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
672def 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
681def 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
690class _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
707def _violation(code: str) -> Result:
708 return Result(False, code)
709
710
711def 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")