|
| 1 | +from __future__ import annotations |
| 2 | +import hashlib |
| 3 | +import json |
| 4 | +import re |
| 5 | +import unicodedata |
| 6 | +from dataclasses import dataclass |
| 7 | +from typing import Any |
| 8 | +SCHEMA = "contract_identity.v2" |
| 9 | +VERSION = "structured_tokens.v2" |
| 10 | +OPS = frozenset({">=", "<=", ">", "<", "==", "!=", "===", "!==", "->", "=>", "::"}) |
| 11 | +PREDICATE_OPS = frozenset({">=", "<=", ">", "<", "==", "!=", "===", "!=="}) |
| 12 | +BEHAVIOR_OPS = frozenset({"==", "!=", "->", "=>"}) |
| 13 | +ORDERING_OPS = frozenset({"->", "=>"}) |
| 14 | +POLICY = frozenset({"required", "forbidden", "present", "absent", "enabled", "disabled", "valid", "invalid", "missing", "optional"}) |
| 15 | +KINDS = frozenset({"identifier", "operator", "policy_state", "secret_ref"}) |
| 16 | +OPERANDS = frozenset({"identifier", "policy_state", "secret_ref"}) |
| 17 | +CATEGORIES = frozenset({"bug", "contract", "logic", "performance", "reliability", "security"}) |
| 18 | +SECRET_TYPES = frozenset({"credential", "authorization", "api_key", "session_token", "private_key"}) |
| 19 | +SECRET_ROLES = frozenset({"auth", "header", "query", "environment", "body"}) |
| 20 | +OWNER = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") |
| 21 | +REPO = re.compile(r"^[A-Za-z0-9._-]{1,100}$") |
| 22 | +IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*(?:\(\))?$") |
| 23 | +DIGEST = re.compile(r"^[0-9a-f]{64}$") |
| 24 | +MAX_ITEMS = 32 |
| 25 | +MAX_BYTES = 64 * 1024 |
| 26 | +class IdentityError(ValueError): |
| 27 | + pass |
| 28 | +@dataclass(frozen=True) |
| 29 | +class CanonicalIdentity: |
| 30 | + _payload_json: str |
| 31 | + contract_key: str |
| 32 | + behavior_digest: str |
| 33 | + fingerprint_v2: str |
| 34 | + @property |
| 35 | + def payload(self) -> dict[str, Any]: |
| 36 | + return json.loads(self._payload_json) |
| 37 | + def as_record(self) -> dict[str, Any]: |
| 38 | + record = self.payload |
| 39 | + record.update(contract_key=self.contract_key, behavior_digest=self.behavior_digest, fingerprint_v2=self.fingerprint_v2) |
| 40 | + return record |
| 41 | +def _obj(value: Any, required: set[str], optional: set[str] = set()) -> dict[str, Any]: |
| 42 | + keys = set(value) if isinstance(value, dict) else set() |
| 43 | + if not isinstance(value, dict) or len(value) > len(required) + len(optional) or not required <= keys or not keys <= required | optional: |
| 44 | + raise IdentityError("invalid object fields") |
| 45 | + return value |
| 46 | +def _text(value: Any, limit: int = 512) -> str: |
| 47 | + if not isinstance(value, str) or len(value) > limit: |
| 48 | + raise IdentityError("invalid bounded text") |
| 49 | + try: |
| 50 | + raw_size = len(value.encode()) |
| 51 | + except UnicodeEncodeError as exc: |
| 52 | + raise IdentityError("invalid unicode text") from exc |
| 53 | + if raw_size > limit: |
| 54 | + raise IdentityError("invalid bounded text") |
| 55 | + value = unicodedata.normalize("NFC", value) |
| 56 | + try: |
| 57 | + normalized_size = len(value.encode()) |
| 58 | + except UnicodeEncodeError as exc: |
| 59 | + raise IdentityError("invalid unicode text") from exc |
| 60 | + if any(unicodedata.category(char).startswith("C") for char in value) or normalized_size > limit: |
| 61 | + raise IdentityError("invalid text") |
| 62 | + return value |
| 63 | +def _scope(value: Any) -> dict[str, str]: |
| 64 | + scope = _obj(value, {"repo", "file", "category"}) |
| 65 | + repo = _text(scope["repo"], 140) |
| 66 | + parts = repo.split("/") |
| 67 | + if len(parts) != 2 or not OWNER.fullmatch(parts[0]) or "--" in parts[0] or not REPO.fullmatch(parts[1]) or parts[1] in {".", ".."}: |
| 68 | + raise IdentityError("invalid repo") |
| 69 | + path = _text(scope["file"], 1024) |
| 70 | + if path.startswith("/") or "\\" in path or any(part in {"", ".", ".."} for part in path.split("/")): |
| 71 | + raise IdentityError("invalid path") |
| 72 | + category = _text(scope["category"], 32) |
| 73 | + if category not in CATEGORIES: |
| 74 | + raise IdentityError("invalid category") |
| 75 | + return {"repo": f"{parts[0].lower()}/{parts[1].lower()}", "file": path, "category": category} |
| 76 | +def _token(value: Any) -> dict[str, Any]: |
| 77 | + token = _obj(value, {"kind", "value"}) |
| 78 | + kind = _text(token["kind"], 32) |
| 79 | + if kind not in KINDS: |
| 80 | + raise IdentityError("unknown token kind") |
| 81 | + if kind == "secret_ref": |
| 82 | + ref = _obj(token["value"], {"type", "role", "position"}) |
| 83 | + typ, role, position = ref["type"], ref["role"], ref["position"] |
| 84 | + if not isinstance(typ, str) or not isinstance(role, str): |
| 85 | + raise IdentityError("invalid secret reference") |
| 86 | + typ, role = _text(typ, 32), _text(role, 32) |
| 87 | + if typ not in SECRET_TYPES or role not in SECRET_ROLES or isinstance(position, bool) or not isinstance(position, int) or not 0 <= position <= 1024: |
| 88 | + raise IdentityError("invalid secret reference") |
| 89 | + return {"kind": kind, "value": {"type": typ, "role": role, "position": position}} |
| 90 | + item = _text(token["value"]) |
| 91 | + if kind == "operator" and item not in OPS or kind == "policy_state" and item not in POLICY or kind == "identifier" and not IDENTIFIER.fullmatch(item): |
| 92 | + raise IdentityError("invalid typed token") |
| 93 | + return {"kind": kind, "value": item} |
| 94 | +def _anchors(value: Any) -> list[dict[str, Any]]: |
| 95 | + if not isinstance(value, list) or not value or len(value) > MAX_ITEMS: |
| 96 | + raise IdentityError("invalid anchors") |
| 97 | + result = [_token(item) for item in value] |
| 98 | + for index, token in enumerate(result): |
| 99 | + if token["kind"] != ("identifier" if index % 2 == 0 else "operator") or token["kind"] == "operator" and token["value"] != "::": |
| 100 | + raise IdentityError("invalid anchor grammar") |
| 101 | + if result[-1]["kind"] != "identifier": |
| 102 | + raise IdentityError("anchor must end with identifier") |
| 103 | + return result |
| 104 | +def _clause(value: Any, operators: frozenset[str], name: str) -> list[dict[str, Any]]: |
| 105 | + if not isinstance(value, list) or not value or len(value) > MAX_ITEMS: |
| 106 | + raise IdentityError(f"invalid {name} clause") |
| 107 | + result = [_token(item) for item in value] |
| 108 | + for index, token in enumerate(result): |
| 109 | + if index % 2 == 0 and token["kind"] not in OPERANDS or index % 2 == 1 and (token["kind"] != "operator" or token["value"] not in operators): |
| 110 | + raise IdentityError(f"invalid {name} grammar") |
| 111 | + if result[-1]["kind"] == "operator": |
| 112 | + raise IdentityError(f"invalid {name} ending") |
| 113 | + secret_indexes = [index for index, token in enumerate(result) if token["kind"] == "secret_ref"] |
| 114 | + if secret_indexes and (secret_indexes != [len(result) - 1] or len(result) < 3 or result[-2]["kind"] != "operator" or result[-2]["value"] not in {"==", "!="}): |
| 115 | + raise IdentityError(f"invalid {name} secret_ref placement") |
| 116 | + return result |
| 117 | +def _clauses(value: Any, operators: frozenset[str], name: str, required: bool) -> list[list[dict[str, Any]]]: |
| 118 | + if not isinstance(value, list) or len(value) > MAX_ITEMS or required and not value: |
| 119 | + raise IdentityError(f"invalid {name} clauses") |
| 120 | + return [_clause(clause, operators, name) for clause in value] |
| 121 | +def _ordering(value: Any) -> list[list[dict[str, Any]]]: |
| 122 | + if not isinstance(value, list) or len(value) > MAX_ITEMS: |
| 123 | + raise IdentityError("invalid ordering constraints") |
| 124 | + result = [] |
| 125 | + for clause in value: |
| 126 | + if not isinstance(clause, list) or len(clause) != 3: |
| 127 | + raise IdentityError("ordering must be one relation") |
| 128 | + tokens = [_token(item) for item in clause] |
| 129 | + if tokens[0]["kind"] != "identifier" or tokens[1]["kind"] != "operator" or tokens[1]["value"] not in ORDERING_OPS or tokens[2]["kind"] != "identifier": |
| 130 | + raise IdentityError("invalid ordering relation") |
| 131 | + result.append(tokens) |
| 132 | + return result |
| 133 | +def _json(value: Any) -> str: |
| 134 | + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) |
| 135 | +def _hash(value: Any) -> str: |
| 136 | + return hashlib.sha256(_json(value).encode()).hexdigest() |
| 137 | +def validate_identity(payload: Any) -> CanonicalIdentity: |
| 138 | + fields = {"schema", "canonicalizer_version", "scope", "anchors", "predicates", "required_behavior", "forbidden_behavior", "ordering_constraints"} |
| 139 | + value = _obj(payload, fields) |
| 140 | + if value["schema"] != SCHEMA or value["canonicalizer_version"] != VERSION: |
| 141 | + raise IdentityError("unsupported schema") |
| 142 | + canonical = {"schema": SCHEMA, "canonicalizer_version": VERSION, "scope": _scope(value["scope"]), "anchors": _anchors(value["anchors"]), "predicates": _clauses(value["predicates"], PREDICATE_OPS, "predicate", True), "required_behavior": _clauses(value["required_behavior"], BEHAVIOR_OPS, "required_behavior", True), "forbidden_behavior": _clauses(value["forbidden_behavior"], BEHAVIOR_OPS, "forbidden_behavior", False), "ordering_constraints": _ordering(value["ordering_constraints"])} |
| 143 | + key = _hash({name: canonical[name] for name in ("schema", "canonicalizer_version", "scope", "anchors", "predicates")}) |
| 144 | + behavior = _hash({"contract_key": key, "required_behavior": canonical["required_behavior"], "forbidden_behavior": canonical["forbidden_behavior"], "ordering_constraints": canonical["ordering_constraints"]}) |
| 145 | + identity = CanonicalIdentity(_json(canonical), key, behavior, _hash({"contract_key": key, "behavior_digest": behavior})) |
| 146 | + if len(_json(identity.as_record()).encode()) > MAX_BYTES: |
| 147 | + raise IdentityError("identity too large") |
| 148 | + return identity |
| 149 | +def verify_identity_record(record: Any) -> CanonicalIdentity: |
| 150 | + payload_fields = {"schema", "canonicalizer_version", "scope", "anchors", "predicates", "required_behavior", "forbidden_behavior", "ordering_constraints"} |
| 151 | + digest_fields = {"contract_key", "behavior_digest", "fingerprint_v2"} |
| 152 | + if not isinstance(record, dict) or set(record) != payload_fields | digest_fields or any(not isinstance(record[name], str) or not DIGEST.fullmatch(record[name]) for name in digest_fields): |
| 153 | + raise IdentityError("invalid verified record") |
| 154 | + identity = validate_identity({name: record[name] for name in payload_fields}) |
| 155 | + if (identity.contract_key, identity.behavior_digest, identity.fingerprint_v2) != tuple(record[name] for name in ("contract_key", "behavior_digest", "fingerprint_v2")) or identity.as_record() != record: |
| 156 | + raise IdentityError("record mismatch") |
| 157 | + return identity |
| 158 | +def canonical_json(identity: CanonicalIdentity) -> str: |
| 159 | + if not isinstance(identity, CanonicalIdentity): |
| 160 | + raise IdentityError("expected identity") |
| 161 | + return _json(identity.as_record()) |
0 commit comments