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/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
+ ]