webagent-protocol 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wap/__init__.py +75 -0
- wap/cli/__init__.py +5 -0
- wap/cli/main.py +369 -0
- wap/client/__init__.py +47 -0
- wap/client/dnskey.py +82 -0
- wap/client/exceptions.py +137 -0
- wap/client/resolver.py +361 -0
- wap/client/session.py +621 -0
- wap/mcp/__init__.py +5 -0
- wap/mcp/bridge.py +793 -0
- wap/mcp/safety.py +120 -0
- wap/py.typed +0 -0
- wap/server/__init__.py +29 -0
- wap/server/admission.py +117 -0
- wap/server/app.py +1071 -0
- wap/server/mcp_endpoint.py +305 -0
- wap/server/mcp_import.py +122 -0
- wap/server/middleware.py +111 -0
- wap/server/observability.py +102 -0
- wap/server/rate_limiter.py +90 -0
- wap/server/router.py +358 -0
- wap/server/state.py +117 -0
- wap/spec/__init__.py +75 -0
- wap/spec/conversation.py +231 -0
- wap/spec/crypto.py +216 -0
- wap/spec/jcs.py +132 -0
- wap/spec/models.py +453 -0
- wap/spec/pow.py +230 -0
- wap/storage/__init__.py +30 -0
- wap/storage/base.py +70 -0
- wap/storage/memory.py +115 -0
- wap/storage/meters.py +204 -0
- wap/storage/redis.py +202 -0
- webagent_protocol-0.2.0.dist-info/METADATA +401 -0
- webagent_protocol-0.2.0.dist-info/RECORD +38 -0
- webagent_protocol-0.2.0.dist-info/WHEEL +4 -0
- webagent_protocol-0.2.0.dist-info/entry_points.txt +3 -0
- webagent_protocol-0.2.0.dist-info/licenses/LICENSE +21 -0
wap/spec/models.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Pydantic v2 data contracts for WebAgent Protocol (WAP/1.0).
|
|
2
|
+
|
|
3
|
+
Every object that crosses the wire is defined here. The models are shared by
|
|
4
|
+
the provider SDK (``wap.server``), the consumer SDK (``wap.client``) and the
|
|
5
|
+
MCP bridge so that both sides of a conversation validate against exactly the
|
|
6
|
+
same contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
19
|
+
|
|
20
|
+
WAP_VERSION = "1.0"
|
|
21
|
+
WELL_KNOWN_PATH = "/.well-known/wap.json"
|
|
22
|
+
# Also served (and tried by clients) for compatibility; other agent formats use this path too,
|
|
23
|
+
# so clients must check that the document is a WAP manifest (it has ``wap_version``).
|
|
24
|
+
LEGACY_WELL_KNOWN_PATH = "/.well-known/agent.json"
|
|
25
|
+
WELL_KNOWN_PATHS = (WELL_KNOWN_PATH, LEGACY_WELL_KNOWN_PATH)
|
|
26
|
+
INTERACT_PATH = "/wap/v1/interact"
|
|
27
|
+
CHALLENGE_PATH = "/wap/v1/challenge"
|
|
28
|
+
|
|
29
|
+
HEADER_VERSION = "X-WAP-Version"
|
|
30
|
+
HEADER_SIGNATURE = "X-WAP-Signature"
|
|
31
|
+
HEADER_KEY_ID = "X-WAP-Key-Id"
|
|
32
|
+
HEADER_AGENT_KEY = "X-WAP-Agent-Key"
|
|
33
|
+
|
|
34
|
+
POW_ALGORITHM = "sha256-leading-zero-hex"
|
|
35
|
+
|
|
36
|
+
EFFECT_RANK: dict[str, int] = {"read": 0, "write": 1, "financial": 2}
|
|
37
|
+
|
|
38
|
+
_HEX_RE = re.compile(r"^[0-9a-f]*$")
|
|
39
|
+
_LABEL_RE = re.compile(r"^(?!-)[a-z0-9-]{1,63}(?<!-)$")
|
|
40
|
+
_CAPABILITY_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now() -> float:
|
|
44
|
+
return time.time()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _new_id() -> str:
|
|
48
|
+
return uuid.uuid4().hex
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_authority(value: str) -> str:
|
|
52
|
+
"""Validate and normalise a WAP ``domain`` (an RFC 3986 authority without userinfo).
|
|
53
|
+
|
|
54
|
+
Accepts a fully-qualified domain name, ``localhost``, or an IPv4 literal,
|
|
55
|
+
optionally followed by ``:port``. The host part is lower-cased and a
|
|
56
|
+
trailing root dot is removed. Raises ``ValueError`` on anything else.
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(value, str) or not value:
|
|
59
|
+
raise ValueError("domain must be a non-empty string")
|
|
60
|
+
raw = value.strip().lower()
|
|
61
|
+
if "://" in raw or "/" in raw or "@" in raw or "?" in raw or "#" in raw:
|
|
62
|
+
raise ValueError(f"domain must be a bare host[:port], got {value!r}")
|
|
63
|
+
host, sep, port = raw.rpartition(":") if raw.count(":") == 1 else (raw, "", "")
|
|
64
|
+
if sep:
|
|
65
|
+
if not port.isdigit() or not 0 < int(port) < 65536:
|
|
66
|
+
raise ValueError(f"invalid port in domain {value!r}")
|
|
67
|
+
host = host.rstrip(".")
|
|
68
|
+
if len(host) > 253 or not host:
|
|
69
|
+
raise ValueError(f"invalid host in domain {value!r}")
|
|
70
|
+
labels = host.split(".")
|
|
71
|
+
if not all(_LABEL_RE.match(label) for label in labels):
|
|
72
|
+
raise ValueError(f"domain {value!r} is not a valid hostname")
|
|
73
|
+
if all(label.isdigit() for label in labels):
|
|
74
|
+
if len(labels) != 4 or any(int(label) > 255 for label in labels):
|
|
75
|
+
raise ValueError(f"domain {value!r} is not a valid IPv4 literal")
|
|
76
|
+
elif len(labels) < 2 and host != "localhost":
|
|
77
|
+
raise ValueError(f"domain {value!r} must be fully qualified (or 'localhost')")
|
|
78
|
+
return f"{host}:{port}" if sep else host
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def authority_host(authority: str) -> str:
|
|
82
|
+
"""Return the host component of a ``host[:port]`` authority."""
|
|
83
|
+
return authority.rsplit(":", 1)[0] if authority.count(":") == 1 else authority
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_local_authority(authority: str) -> bool:
|
|
87
|
+
"""True for loopback authorities where plain HTTP is acceptable."""
|
|
88
|
+
host = authority_host(authority)
|
|
89
|
+
return host == "localhost" or host.endswith(".localhost") or host.startswith("127.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_ijson(value: Any, path: str = "structured_data") -> None:
|
|
93
|
+
"""I-JSON (RFC 7493): numbers must be exact IEEE-754 doubles so every language signs the same bytes."""
|
|
94
|
+
if isinstance(value, bool):
|
|
95
|
+
return
|
|
96
|
+
if isinstance(value, int):
|
|
97
|
+
if abs(value) > 2**53:
|
|
98
|
+
raise ValueError(f"{path}: integer {value} exceeds ±2**53; send it as a string")
|
|
99
|
+
elif isinstance(value, float):
|
|
100
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
101
|
+
raise ValueError(f"{path}: NaN and infinities are not allowed")
|
|
102
|
+
elif isinstance(value, dict):
|
|
103
|
+
for key, item in value.items():
|
|
104
|
+
_check_ijson(item, f"{path}.{key}")
|
|
105
|
+
elif isinstance(value, list):
|
|
106
|
+
for index, item in enumerate(value):
|
|
107
|
+
_check_ijson(item, f"{path}[{index}]")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Role(StrEnum):
|
|
111
|
+
USER_AGENT = "user_agent"
|
|
112
|
+
BUSINESS_AGENT = "business_agent"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ErrorCode(StrEnum):
|
|
116
|
+
"""Machine-readable error codes (see docs/spec_rfc.md, Section 9)."""
|
|
117
|
+
|
|
118
|
+
INVALID_REQUEST = "invalid_request"
|
|
119
|
+
UNSUPPORTED_VERSION = "unsupported_version"
|
|
120
|
+
INVALID_SIGNATURE = "invalid_signature"
|
|
121
|
+
AUTH_REQUIRED = "auth_required"
|
|
122
|
+
FORBIDDEN = "forbidden"
|
|
123
|
+
SESSION_BUSY = "session_busy"
|
|
124
|
+
IDEMPOTENCY_CONFLICT = "idempotency_conflict"
|
|
125
|
+
EFFECTS_NOT_PERMITTED = "effects_not_permitted"
|
|
126
|
+
UNKNOWN_CAPABILITY = "unknown_capability"
|
|
127
|
+
REPLAY_DETECTED = "replay_detected"
|
|
128
|
+
VALIDATION_ERROR = "validation_error"
|
|
129
|
+
POW_REQUIRED = "pow_required"
|
|
130
|
+
POW_INVALID = "pow_invalid"
|
|
131
|
+
RATE_LIMITED = "rate_limited"
|
|
132
|
+
LOOP_DETECTED = "loop_detected"
|
|
133
|
+
CONVERSATION_LIMIT = "conversation_limit"
|
|
134
|
+
ACTION_FAILED = "action_failed"
|
|
135
|
+
INTERNAL_ERROR = "internal_error"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ERROR_STATUS: dict[ErrorCode, int] = {
|
|
139
|
+
ErrorCode.INVALID_REQUEST: 400,
|
|
140
|
+
ErrorCode.UNSUPPORTED_VERSION: 400,
|
|
141
|
+
ErrorCode.INVALID_SIGNATURE: 401,
|
|
142
|
+
ErrorCode.AUTH_REQUIRED: 401,
|
|
143
|
+
ErrorCode.FORBIDDEN: 403,
|
|
144
|
+
ErrorCode.SESSION_BUSY: 409,
|
|
145
|
+
ErrorCode.IDEMPOTENCY_CONFLICT: 409,
|
|
146
|
+
ErrorCode.EFFECTS_NOT_PERMITTED: 403,
|
|
147
|
+
ErrorCode.POW_INVALID: 403,
|
|
148
|
+
ErrorCode.UNKNOWN_CAPABILITY: 404,
|
|
149
|
+
ErrorCode.REPLAY_DETECTED: 409,
|
|
150
|
+
ErrorCode.VALIDATION_ERROR: 422,
|
|
151
|
+
ErrorCode.POW_REQUIRED: 428,
|
|
152
|
+
ErrorCode.RATE_LIMITED: 429,
|
|
153
|
+
ErrorCode.LOOP_DETECTED: 409,
|
|
154
|
+
ErrorCode.CONVERSATION_LIMIT: 429,
|
|
155
|
+
ErrorCode.ACTION_FAILED: 502,
|
|
156
|
+
ErrorCode.INTERNAL_ERROR: 500,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class WAPModel(BaseModel):
|
|
161
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Capability(WAPModel):
|
|
165
|
+
"""A machine-executable action offered by a business agent."""
|
|
166
|
+
|
|
167
|
+
id: str = Field(description="Stable identifier, unique within a manifest.")
|
|
168
|
+
name: str = Field(min_length=1, max_length=128, description="Human readable name.")
|
|
169
|
+
description: str = Field(default="", max_length=4096)
|
|
170
|
+
input_schema: dict[str, Any] = Field(
|
|
171
|
+
default_factory=lambda: {"type": "object", "properties": {}},
|
|
172
|
+
description="JSON Schema (draft 2020-12) for the capability's structured_data input.",
|
|
173
|
+
)
|
|
174
|
+
output_schema: dict[str, Any] | None = Field(
|
|
175
|
+
default=None, description="JSON Schema describing structured_data in the reply."
|
|
176
|
+
)
|
|
177
|
+
requires_auth: bool = False
|
|
178
|
+
effects: Literal["read", "write", "financial"] = Field(
|
|
179
|
+
default="write",
|
|
180
|
+
description=(
|
|
181
|
+
"What calling the capability does: 'read' has no side effects; 'write' changes state at the "
|
|
182
|
+
"business (holds, bookings); 'financial' moves money or creates a payment obligation. "
|
|
183
|
+
"Clients use it to decide on confirmation, retries and idempotency keys."
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
streaming: bool = Field(default=False, description="Whether the action streams tokens natively.")
|
|
187
|
+
|
|
188
|
+
@field_validator("id")
|
|
189
|
+
@classmethod
|
|
190
|
+
def _check_id(cls, value: str) -> str:
|
|
191
|
+
if not _CAPABILITY_ID_RE.match(value):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"capability id must match ^[a-z][a-z0-9_.-]{0,63}$ "
|
|
194
|
+
f"(lower-case, starting with a letter), got {value!r}"
|
|
195
|
+
)
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
@field_validator("input_schema")
|
|
199
|
+
@classmethod
|
|
200
|
+
def _check_input_schema(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
201
|
+
if value.get("type", "object") != "object":
|
|
202
|
+
raise ValueError("input_schema must describe a JSON object")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def mcp_annotation_hints(capability: Capability) -> dict[str, bool]:
|
|
207
|
+
"""MCP ``ToolAnnotations`` hints implied by a capability's ``effects``."""
|
|
208
|
+
return {
|
|
209
|
+
"read_only_hint": capability.effects == "read",
|
|
210
|
+
"destructive_hint": capability.effects == "financial",
|
|
211
|
+
"idempotent_hint": capability.effects == "read",
|
|
212
|
+
"open_world_hint": True,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RateLimitPolicy(WAPModel):
|
|
217
|
+
"""Advisory rate limit published in the manifest and enforced by the server."""
|
|
218
|
+
|
|
219
|
+
requests_per_minute: int = Field(default=60, ge=1)
|
|
220
|
+
burst: int = Field(default=10, ge=1)
|
|
221
|
+
scopes: list[Literal["ip", "agent_key"]] = Field(default_factory=lambda: ["ip", "agent_key"])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AgentManifest(WAPModel):
|
|
225
|
+
"""The document served at ``/.well-known/wap.json`` (RFC 8615)."""
|
|
226
|
+
|
|
227
|
+
wap_version: Literal["1.0"] = WAP_VERSION
|
|
228
|
+
domain: str = Field(description="Authority (host[:port]) the manifest is bound to.")
|
|
229
|
+
name: str = Field(min_length=1, max_length=256)
|
|
230
|
+
description: str = Field(default="", max_length=8192)
|
|
231
|
+
public_key: str = Field(description="Hex-encoded raw 32-byte Ed25519 public key.")
|
|
232
|
+
interaction_url: str
|
|
233
|
+
challenge_url: str | None = None
|
|
234
|
+
mcp_url: str | None = Field(
|
|
235
|
+
default=None, description="Optional MCP streamable-HTTP endpoint exposing the same capabilities as tools."
|
|
236
|
+
)
|
|
237
|
+
capabilities: list[Capability] = Field(default_factory=list)
|
|
238
|
+
pow_required: bool = False
|
|
239
|
+
pow_difficulty: int | None = Field(default=None, ge=1, le=16)
|
|
240
|
+
rate_limit_policy: dict[str, Any] = Field(default_factory=lambda: RateLimitPolicy().model_dump())
|
|
241
|
+
conversation_policy: dict[str, Any] | None = Field(
|
|
242
|
+
default=None, description="Loop-protection limits (max_turns, max_repeats, max_cycle_length, window_seconds)."
|
|
243
|
+
)
|
|
244
|
+
issued_at: float = Field(default_factory=_now)
|
|
245
|
+
expires_at: float | None = None
|
|
246
|
+
previous_keys: list[str] = Field(
|
|
247
|
+
default_factory=list, description="Hex keys this domain signed with before rotating to public_key."
|
|
248
|
+
)
|
|
249
|
+
key_endorsements: dict[str, str] = Field(
|
|
250
|
+
default_factory=dict,
|
|
251
|
+
description="previous key -> its signature over this manifest (excluding signature and key_endorsements).",
|
|
252
|
+
)
|
|
253
|
+
signature: str = Field(default="", description="Hex Ed25519 signature over the canonical manifest.")
|
|
254
|
+
|
|
255
|
+
@field_validator("domain")
|
|
256
|
+
@classmethod
|
|
257
|
+
def _check_domain(cls, value: str) -> str:
|
|
258
|
+
return normalize_authority(value)
|
|
259
|
+
|
|
260
|
+
@field_validator("public_key")
|
|
261
|
+
@classmethod
|
|
262
|
+
def _check_public_key(cls, value: str) -> str:
|
|
263
|
+
value = value.lower()
|
|
264
|
+
if len(value) != 64 or not _HEX_RE.match(value):
|
|
265
|
+
raise ValueError("public_key must be 64 hex characters (raw Ed25519 key)")
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
@field_validator("signature")
|
|
269
|
+
@classmethod
|
|
270
|
+
def _check_signature(cls, value: str) -> str:
|
|
271
|
+
value = value.lower()
|
|
272
|
+
if value and (len(value) != 128 or not _HEX_RE.match(value)):
|
|
273
|
+
raise ValueError("signature must be 128 hex characters (raw Ed25519 signature)")
|
|
274
|
+
return value
|
|
275
|
+
|
|
276
|
+
@field_validator("interaction_url", "challenge_url", "mcp_url")
|
|
277
|
+
@classmethod
|
|
278
|
+
def _check_url(cls, value: str | None) -> str | None:
|
|
279
|
+
if value is None:
|
|
280
|
+
return value
|
|
281
|
+
parts = urlsplit(value)
|
|
282
|
+
if parts.scheme not in ("https", "http") or not parts.netloc:
|
|
283
|
+
raise ValueError(f"URL must be absolute http(s), got {value!r}")
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
@model_validator(mode="after")
|
|
287
|
+
def _check_consistency(self) -> AgentManifest:
|
|
288
|
+
ids = [c.id for c in self.capabilities]
|
|
289
|
+
duplicates = {i for i in ids if ids.count(i) > 1}
|
|
290
|
+
if duplicates:
|
|
291
|
+
raise ValueError(f"duplicate capability ids: {sorted(duplicates)}")
|
|
292
|
+
if self.pow_required and self.pow_difficulty is None:
|
|
293
|
+
raise ValueError("pow_difficulty is required when pow_required is true")
|
|
294
|
+
if self.pow_required and self.challenge_url is None:
|
|
295
|
+
raise ValueError("challenge_url is required when pow_required is true")
|
|
296
|
+
for key in [*self.previous_keys, *self.key_endorsements]:
|
|
297
|
+
if len(key) != 64 or not _HEX_RE.match(key):
|
|
298
|
+
raise ValueError("previous_keys / key_endorsements must use 64-char lower-case hex keys")
|
|
299
|
+
if set(self.key_endorsements) - set(self.previous_keys):
|
|
300
|
+
raise ValueError("key_endorsements may only contain keys listed in previous_keys")
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def get_capability(self, capability_id: str) -> Capability | None:
|
|
304
|
+
for capability in self.capabilities:
|
|
305
|
+
if capability.id == capability_id:
|
|
306
|
+
return capability
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def is_expired(self, now: float | None = None) -> bool:
|
|
310
|
+
return self.expires_at is not None and (now or time.time()) >= self.expires_at
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class AgentMessage(WAPModel):
|
|
314
|
+
"""A single signed turn in a WAP dialogue."""
|
|
315
|
+
|
|
316
|
+
wap_version: Literal["1.0"] = WAP_VERSION
|
|
317
|
+
message_id: str = Field(default_factory=_new_id, min_length=8, max_length=128)
|
|
318
|
+
session_id: str = Field(min_length=1, max_length=128)
|
|
319
|
+
role: Role
|
|
320
|
+
content: str = Field(default="", max_length=65536)
|
|
321
|
+
capability_id: str | None = None
|
|
322
|
+
structured_data: dict[str, Any] | None = None
|
|
323
|
+
in_reply_to: str | None = None
|
|
324
|
+
max_effects: Literal["read", "write", "financial"] | None = Field(
|
|
325
|
+
default=None,
|
|
326
|
+
description=(
|
|
327
|
+
"The strongest side effect the sender permits for this turn. A business agent must not run a "
|
|
328
|
+
"capability with stronger effects (e.g. an intent router booking something when only 'read' is "
|
|
329
|
+
"permitted) and instead answers effects_not_permitted naming the capability, so the user can confirm."
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
idempotency_key: str | None = Field(
|
|
333
|
+
default=None,
|
|
334
|
+
min_length=8,
|
|
335
|
+
max_length=128,
|
|
336
|
+
description="Client-chosen key; a retry with the same key returns the original result instead of re-running.",
|
|
337
|
+
)
|
|
338
|
+
timestamp: float = Field(default_factory=_now)
|
|
339
|
+
pow_seed: str | None = None
|
|
340
|
+
pow_nonce: str | None = Field(default=None, max_length=128)
|
|
341
|
+
public_key: str | None = Field(
|
|
342
|
+
default=None, description="Signer's hex Ed25519 key. Required for user_agent messages."
|
|
343
|
+
)
|
|
344
|
+
signature: str = ""
|
|
345
|
+
|
|
346
|
+
@field_validator("public_key")
|
|
347
|
+
@classmethod
|
|
348
|
+
def _check_public_key(cls, value: str | None) -> str | None:
|
|
349
|
+
if value is None:
|
|
350
|
+
return value
|
|
351
|
+
value = value.lower()
|
|
352
|
+
if len(value) != 64 or not _HEX_RE.match(value):
|
|
353
|
+
raise ValueError("public_key must be 64 hex characters")
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
@field_validator("signature")
|
|
357
|
+
@classmethod
|
|
358
|
+
def _check_signature(cls, value: str) -> str:
|
|
359
|
+
value = value.lower()
|
|
360
|
+
if value and (len(value) != 128 or not _HEX_RE.match(value)):
|
|
361
|
+
raise ValueError("signature must be 128 hex characters")
|
|
362
|
+
return value
|
|
363
|
+
|
|
364
|
+
@field_validator("structured_data")
|
|
365
|
+
@classmethod
|
|
366
|
+
def _check_interoperable_numbers(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
367
|
+
if value is not None:
|
|
368
|
+
_check_ijson(value)
|
|
369
|
+
return value
|
|
370
|
+
|
|
371
|
+
@model_validator(mode="after")
|
|
372
|
+
def _check_pow_pair(self) -> AgentMessage:
|
|
373
|
+
if (self.pow_seed is None) != (self.pow_nonce is None):
|
|
374
|
+
raise ValueError("pow_seed and pow_nonce must be supplied together")
|
|
375
|
+
return self
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class Challenge(WAPModel):
|
|
379
|
+
"""A Hashcash-style proof-of-work challenge issued by ``/wap/v1/challenge``."""
|
|
380
|
+
|
|
381
|
+
algorithm: Literal["sha256-leading-zero-hex"] = POW_ALGORITHM
|
|
382
|
+
seed: str = Field(min_length=16, max_length=256)
|
|
383
|
+
difficulty: int = Field(ge=1, le=16)
|
|
384
|
+
issued_at: float = Field(default_factory=_now)
|
|
385
|
+
expires_at: float
|
|
386
|
+
|
|
387
|
+
@field_validator("seed")
|
|
388
|
+
@classmethod
|
|
389
|
+
def _check_seed(cls, value: str) -> str:
|
|
390
|
+
if not _HEX_RE.match(value):
|
|
391
|
+
raise ValueError("seed must be lower-case hex")
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class ErrorDetail(WAPModel):
|
|
396
|
+
code: ErrorCode
|
|
397
|
+
message: str
|
|
398
|
+
retry_after: float | None = None
|
|
399
|
+
details: dict[str, Any] | None = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class ErrorResponse(WAPModel):
|
|
403
|
+
error: ErrorDetail
|
|
404
|
+
|
|
405
|
+
@classmethod
|
|
406
|
+
def build(
|
|
407
|
+
cls,
|
|
408
|
+
code: ErrorCode,
|
|
409
|
+
message: str,
|
|
410
|
+
*,
|
|
411
|
+
retry_after: float | None = None,
|
|
412
|
+
details: dict[str, Any] | None = None,
|
|
413
|
+
) -> ErrorResponse:
|
|
414
|
+
return cls(error=ErrorDetail(code=code, message=message, retry_after=retry_after, details=details))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class StreamEventType(StrEnum):
|
|
418
|
+
META = "meta"
|
|
419
|
+
TOKEN = "token"
|
|
420
|
+
DATA = "data"
|
|
421
|
+
MESSAGE = "message"
|
|
422
|
+
ERROR = "error"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
__all__ = [
|
|
426
|
+
"WAP_VERSION",
|
|
427
|
+
"LEGACY_WELL_KNOWN_PATH",
|
|
428
|
+
"WELL_KNOWN_PATH",
|
|
429
|
+
"WELL_KNOWN_PATHS",
|
|
430
|
+
"INTERACT_PATH",
|
|
431
|
+
"CHALLENGE_PATH",
|
|
432
|
+
"HEADER_VERSION",
|
|
433
|
+
"HEADER_SIGNATURE",
|
|
434
|
+
"HEADER_KEY_ID",
|
|
435
|
+
"HEADER_AGENT_KEY",
|
|
436
|
+
"POW_ALGORITHM",
|
|
437
|
+
"EFFECT_RANK",
|
|
438
|
+
"ERROR_STATUS",
|
|
439
|
+
"AgentManifest",
|
|
440
|
+
"AgentMessage",
|
|
441
|
+
"Capability",
|
|
442
|
+
"Challenge",
|
|
443
|
+
"ErrorCode",
|
|
444
|
+
"ErrorDetail",
|
|
445
|
+
"ErrorResponse",
|
|
446
|
+
"RateLimitPolicy",
|
|
447
|
+
"Role",
|
|
448
|
+
"StreamEventType",
|
|
449
|
+
"authority_host",
|
|
450
|
+
"is_local_authority",
|
|
451
|
+
"mcp_annotation_hints",
|
|
452
|
+
"normalize_authority",
|
|
453
|
+
]
|
wap/spec/pow.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Hashcash-style proof-of-work used to defend business agents against token draining.
|
|
2
|
+
|
|
3
|
+
A challenge is ``{"seed": "<hex>", "difficulty": N}``. A client solves it by
|
|
4
|
+
finding a ``nonce`` such that ``SHA256(seed + nonce)`` (UTF-8 concatenation,
|
|
5
|
+
hex digest) starts with ``N`` ``"0"`` characters. The expected work is
|
|
6
|
+
``16**N`` hash evaluations for the client and exactly one for the server.
|
|
7
|
+
|
|
8
|
+
Challenges are *stateless* to issue: the seed embeds a random salt, its expiry
|
|
9
|
+
time and an HMAC tag computed with a server secret, so the server does not need
|
|
10
|
+
to remember what it handed out. It only remembers *spent* seeds (until they
|
|
11
|
+
expire) so a solution cannot be replayed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import itertools
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import struct
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from .models import Challenge
|
|
28
|
+
|
|
29
|
+
_SALT_BYTES = 16
|
|
30
|
+
_EXPIRY_BYTES = 8
|
|
31
|
+
_TAG_BYTES = 16
|
|
32
|
+
SEED_HEX_LENGTH = 2 * (_SALT_BYTES + _EXPIRY_BYTES + _TAG_BYTES)
|
|
33
|
+
MAX_NONCE_LENGTH = 128
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PowError(Exception):
|
|
37
|
+
"""Base class for proof-of-work verification failures."""
|
|
38
|
+
|
|
39
|
+
reason = "invalid"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PowMalformed(PowError):
|
|
43
|
+
reason = "malformed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PowForged(PowError):
|
|
47
|
+
reason = "forged"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PowExpired(PowError):
|
|
51
|
+
reason = "expired"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PowReplayed(PowError):
|
|
55
|
+
reason = "replayed"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PowInsufficient(PowError):
|
|
59
|
+
reason = "insufficient_work"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pow_digest(seed: str, nonce: str) -> str:
|
|
63
|
+
return hashlib.sha256((seed + nonce).encode("utf-8")).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_solution(seed: str, nonce: str, difficulty: int) -> bool:
|
|
67
|
+
"""Return ``True`` iff ``nonce`` solves ``seed`` at ``difficulty``."""
|
|
68
|
+
if not nonce or len(nonce) > MAX_NONCE_LENGTH:
|
|
69
|
+
return False
|
|
70
|
+
return pow_digest(seed, nonce).startswith("0" * difficulty)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def solve(seed: str, difficulty: int, *, start: int = 0, max_iterations: int | None = None) -> str:
|
|
74
|
+
"""Find a nonce for ``seed`` by brute force. CPU bound; see :func:`solve_async`."""
|
|
75
|
+
if difficulty < 1:
|
|
76
|
+
raise ValueError("difficulty must be >= 1")
|
|
77
|
+
target = "0" * difficulty
|
|
78
|
+
prefix = hashlib.sha256(seed.encode("utf-8"))
|
|
79
|
+
counter = itertools.count(start) if max_iterations is None else range(start, start + max_iterations)
|
|
80
|
+
for i in counter:
|
|
81
|
+
nonce = format(i, "x")
|
|
82
|
+
h = prefix.copy()
|
|
83
|
+
h.update(nonce.encode("ascii"))
|
|
84
|
+
if h.hexdigest().startswith(target):
|
|
85
|
+
return nonce
|
|
86
|
+
raise RuntimeError(f"no solution found within {max_iterations} iterations")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def solve_async(seed: str, difficulty: int) -> str:
|
|
90
|
+
"""Solve a challenge in a worker thread so the event loop stays responsive."""
|
|
91
|
+
return await asyncio.to_thread(solve, seed, difficulty)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def solve_challenge(challenge: Challenge) -> str:
|
|
95
|
+
return solve(challenge.seed, challenge.difficulty)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class PowVerification:
|
|
100
|
+
seed: str
|
|
101
|
+
nonce: str
|
|
102
|
+
difficulty: int
|
|
103
|
+
expires_at: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PowEngine:
|
|
107
|
+
"""Issues and verifies stateless, single-use proof-of-work challenges."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
difficulty: int = 4,
|
|
112
|
+
ttl_seconds: float = 120.0,
|
|
113
|
+
secret: bytes | None = None,
|
|
114
|
+
max_spent: int = 100_000,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not 1 <= difficulty <= 16:
|
|
117
|
+
raise ValueError("difficulty must be between 1 and 16")
|
|
118
|
+
if ttl_seconds <= 0:
|
|
119
|
+
raise ValueError("ttl_seconds must be positive")
|
|
120
|
+
self.difficulty = difficulty
|
|
121
|
+
self.ttl_seconds = ttl_seconds
|
|
122
|
+
self._secret = secret or os.urandom(32)
|
|
123
|
+
self._spent: dict[str, float] = {}
|
|
124
|
+
self._max_spent = max_spent
|
|
125
|
+
self._lock = threading.Lock()
|
|
126
|
+
|
|
127
|
+
def _tag(self, salt: bytes, expiry: bytes, difficulty: int) -> bytes:
|
|
128
|
+
mac = hmac.new(self._secret, salt + expiry + bytes([difficulty]), hashlib.sha256)
|
|
129
|
+
return mac.digest()[:_TAG_BYTES]
|
|
130
|
+
|
|
131
|
+
def issue(self, difficulty: int | None = None, now: float | None = None) -> Challenge:
|
|
132
|
+
"""Mint a new challenge. The difficulty is authenticated by the seed's HMAC."""
|
|
133
|
+
difficulty = self.difficulty if difficulty is None else difficulty
|
|
134
|
+
if not 1 <= difficulty <= 16:
|
|
135
|
+
raise ValueError("difficulty must be between 1 and 16")
|
|
136
|
+
now = time.time() if now is None else now
|
|
137
|
+
expires_at = now + self.ttl_seconds
|
|
138
|
+
salt = secrets.token_bytes(_SALT_BYTES)
|
|
139
|
+
expiry = struct.pack(">d", expires_at)
|
|
140
|
+
seed = (salt + expiry + self._tag(salt, expiry, difficulty)).hex()
|
|
141
|
+
return Challenge(seed=seed, difficulty=difficulty, issued_at=now, expires_at=expires_at)
|
|
142
|
+
|
|
143
|
+
def _decode(self, seed: str) -> tuple[bytes, bytes, bytes, float]:
|
|
144
|
+
if len(seed) != SEED_HEX_LENGTH:
|
|
145
|
+
raise PowMalformed("seed has the wrong length")
|
|
146
|
+
try:
|
|
147
|
+
raw = bytes.fromhex(seed)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise PowMalformed("seed is not hex") from exc
|
|
150
|
+
salt = raw[:_SALT_BYTES]
|
|
151
|
+
expiry = raw[_SALT_BYTES : _SALT_BYTES + _EXPIRY_BYTES]
|
|
152
|
+
tag = raw[_SALT_BYTES + _EXPIRY_BYTES :]
|
|
153
|
+
(expires_at,) = struct.unpack(">d", expiry)
|
|
154
|
+
return salt, expiry, tag, expires_at
|
|
155
|
+
|
|
156
|
+
def _authenticate(self, seed: str) -> tuple[int, float]:
|
|
157
|
+
"""Recover the difficulty bound into ``seed`` and its expiry, or raise."""
|
|
158
|
+
salt, expiry, tag, expires_at = self._decode(seed)
|
|
159
|
+
for difficulty in range(1, 17):
|
|
160
|
+
if hmac.compare_digest(tag, self._tag(salt, expiry, difficulty)):
|
|
161
|
+
return difficulty, expires_at
|
|
162
|
+
raise PowForged("seed was not issued by this server")
|
|
163
|
+
|
|
164
|
+
def _prune(self, now: float) -> None:
|
|
165
|
+
expired = [s for s, exp in self._spent.items() if exp <= now]
|
|
166
|
+
for s in expired:
|
|
167
|
+
del self._spent[s]
|
|
168
|
+
|
|
169
|
+
def check(
|
|
170
|
+
self, seed: str, nonce: str, now: float | None = None, *, min_difficulty: int | None = None
|
|
171
|
+
) -> PowVerification:
|
|
172
|
+
"""Verify authenticity, expiry and work *without* tracking reuse (see :meth:`verify`).
|
|
173
|
+
|
|
174
|
+
Servers with several workers call this and then record the seed as spent in a
|
|
175
|
+
shared store. Raises a :class:`PowError` subclass on failure.
|
|
176
|
+
"""
|
|
177
|
+
now = time.time() if now is None else now
|
|
178
|
+
if not isinstance(nonce, str) or not nonce or len(nonce) > MAX_NONCE_LENGTH:
|
|
179
|
+
raise PowMalformed("nonce is missing or too long")
|
|
180
|
+
difficulty, expires_at = self._authenticate(seed)
|
|
181
|
+
if expires_at <= now:
|
|
182
|
+
raise PowExpired("challenge has expired")
|
|
183
|
+
if min_difficulty is not None and difficulty < min_difficulty:
|
|
184
|
+
raise PowInsufficient(f"challenge difficulty {difficulty} is below the required {min_difficulty}")
|
|
185
|
+
if not check_solution(seed, nonce, difficulty):
|
|
186
|
+
raise PowInsufficient(f"hash does not have {difficulty} leading zeros")
|
|
187
|
+
return PowVerification(seed=seed, nonce=nonce, difficulty=difficulty, expires_at=expires_at)
|
|
188
|
+
|
|
189
|
+
def verify(self, seed: str, nonce: str, now: float | None = None, *, consume: bool = True) -> PowVerification:
|
|
190
|
+
"""Verify a solution and (by default) mark the seed as spent in this process.
|
|
191
|
+
|
|
192
|
+
Raises a :class:`PowError` subclass describing why verification failed.
|
|
193
|
+
"""
|
|
194
|
+
now = time.time() if now is None else now
|
|
195
|
+
result = self.check(seed, nonce, now)
|
|
196
|
+
expires_at = result.expires_at
|
|
197
|
+
with self._lock:
|
|
198
|
+
if seed in self._spent:
|
|
199
|
+
raise PowReplayed("challenge has already been used")
|
|
200
|
+
if consume:
|
|
201
|
+
if len(self._spent) >= self._max_spent:
|
|
202
|
+
self._prune(now)
|
|
203
|
+
if len(self._spent) >= self._max_spent:
|
|
204
|
+
raise PowReplayed("too many outstanding solutions; retry shortly")
|
|
205
|
+
self._spent[seed] = expires_at
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
def spent_count(self) -> int:
|
|
209
|
+
with self._lock:
|
|
210
|
+
self._prune(time.time())
|
|
211
|
+
return len(self._spent)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"MAX_NONCE_LENGTH",
|
|
216
|
+
"PowEngine",
|
|
217
|
+
"PowError",
|
|
218
|
+
"PowExpired",
|
|
219
|
+
"PowForged",
|
|
220
|
+
"PowInsufficient",
|
|
221
|
+
"PowMalformed",
|
|
222
|
+
"PowReplayed",
|
|
223
|
+
"PowVerification",
|
|
224
|
+
"SEED_HEX_LENGTH",
|
|
225
|
+
"check_solution",
|
|
226
|
+
"pow_digest",
|
|
227
|
+
"solve",
|
|
228
|
+
"solve_async",
|
|
229
|
+
"solve_challenge",
|
|
230
|
+
]
|