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/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""WebAgent Protocol (WAP): discovery and negotiation between AI agents on the open web.
|
|
2
|
+
|
|
3
|
+
The core package ships the specification models and the async consumer SDK::
|
|
4
|
+
|
|
5
|
+
from wap import WAPClient
|
|
6
|
+
|
|
7
|
+
async with WAPClient() as client:
|
|
8
|
+
result = await client.ask("bakery.example", "Do you have sourdough croissants?")
|
|
9
|
+
|
|
10
|
+
The provider SDK lives in :mod:`wap.server` (``pip install "webagent-protocol[server]"``) and
|
|
11
|
+
the Model Context Protocol bridge in :mod:`wap.mcp` (``pip install "webagent-protocol[mcp]"``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
from .client import (
|
|
17
|
+
ConfirmationRequest,
|
|
18
|
+
InteractionResult,
|
|
19
|
+
ManifestResolver,
|
|
20
|
+
StreamEvent,
|
|
21
|
+
WAPClient,
|
|
22
|
+
WAPSession,
|
|
23
|
+
)
|
|
24
|
+
from .client.exceptions import (
|
|
25
|
+
CapabilityNotFound,
|
|
26
|
+
ConfirmationDeclined,
|
|
27
|
+
ConversationLimitReached,
|
|
28
|
+
ConversationStopped,
|
|
29
|
+
LoopDetected,
|
|
30
|
+
ManifestNotFound,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
RateLimited,
|
|
33
|
+
SchemaValidationError,
|
|
34
|
+
VerificationFailed,
|
|
35
|
+
WAPError,
|
|
36
|
+
)
|
|
37
|
+
from .spec import (
|
|
38
|
+
WAP_VERSION,
|
|
39
|
+
AgentManifest,
|
|
40
|
+
AgentMessage,
|
|
41
|
+
Capability,
|
|
42
|
+
Challenge,
|
|
43
|
+
Signer,
|
|
44
|
+
generate_keypair,
|
|
45
|
+
)
|
|
46
|
+
from .spec.conversation import ConversationPolicy
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"__version__",
|
|
50
|
+
"WAP_VERSION",
|
|
51
|
+
"AgentManifest",
|
|
52
|
+
"AgentMessage",
|
|
53
|
+
"Capability",
|
|
54
|
+
"CapabilityNotFound",
|
|
55
|
+
"Challenge",
|
|
56
|
+
"ConfirmationDeclined",
|
|
57
|
+
"ConfirmationRequest",
|
|
58
|
+
"ConversationLimitReached",
|
|
59
|
+
"ConversationPolicy",
|
|
60
|
+
"ConversationStopped",
|
|
61
|
+
"InteractionResult",
|
|
62
|
+
"LoopDetected",
|
|
63
|
+
"ManifestNotFound",
|
|
64
|
+
"ManifestResolver",
|
|
65
|
+
"ProtocolError",
|
|
66
|
+
"RateLimited",
|
|
67
|
+
"SchemaValidationError",
|
|
68
|
+
"Signer",
|
|
69
|
+
"StreamEvent",
|
|
70
|
+
"VerificationFailed",
|
|
71
|
+
"WAPClient",
|
|
72
|
+
"WAPError",
|
|
73
|
+
"WAPSession",
|
|
74
|
+
"generate_keypair",
|
|
75
|
+
]
|
wap/cli/__init__.py
ADDED
wap/cli/main.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""``wap`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
|
|
5
|
+
* ``wap ask <domain> "<query>"`` single-shot query with live streaming output
|
|
6
|
+
* ``wap inspect <domain>`` pretty-print a verified manifest and capability schemas
|
|
7
|
+
* ``wap verify <domain>`` report every verification check performed on a manifest
|
|
8
|
+
* ``wap keygen`` generate an Ed25519 key pair for manifest signing
|
|
9
|
+
* ``wap serve module:app`` run a WAP-enabled ASGI application with uvicorn
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Annotated, Any
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.json import JSON
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
|
|
30
|
+
from .. import __version__
|
|
31
|
+
from ..client import WAPClient, WAPError
|
|
32
|
+
from ..client.dnskey import txt_record_for
|
|
33
|
+
from ..client.exceptions import ProtocolError, SchemaValidationError
|
|
34
|
+
from ..spec.crypto import fingerprint, generate_keypair
|
|
35
|
+
from ..spec.models import AgentManifest, StreamEventType
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
name="wap",
|
|
39
|
+
help="WebAgent Protocol: discover and talk to business AI agents across the open web.",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
add_completion=False,
|
|
42
|
+
)
|
|
43
|
+
console = Console()
|
|
44
|
+
err_console = Console(stderr=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
console.print(f"wap {__version__} (WAP/1.0)")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utf8_output() -> None:
|
|
54
|
+
"""Write UTF-8 even where the locale says otherwise (e.g. Windows with output redirected to a file)."""
|
|
55
|
+
for stream in (sys.stdout, sys.stderr):
|
|
56
|
+
if (getattr(stream, "encoding", None) or "").lower().replace("-", "") != "utf8":
|
|
57
|
+
try:
|
|
58
|
+
stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
59
|
+
except (AttributeError, ValueError):
|
|
60
|
+
pass # not a TextIOWrapper (e.g. a test runner's capture); leave it alone
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.callback()
|
|
64
|
+
def _root(
|
|
65
|
+
version: Annotated[
|
|
66
|
+
bool, typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version.")
|
|
67
|
+
] = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""WebAgent Protocol CLI."""
|
|
70
|
+
_utf8_output()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _client(insecure: bool, pin: list[str] | None, token: str | None, domain: str | None, timeout: float) -> WAPClient:
|
|
74
|
+
pinned: dict[str, str] = {}
|
|
75
|
+
for item in pin or []:
|
|
76
|
+
host, sep, key = item.partition("=")
|
|
77
|
+
if not sep:
|
|
78
|
+
raise typer.BadParameter(f"--pin expects domain=<hex public key>, got {item!r}")
|
|
79
|
+
pinned[host] = key
|
|
80
|
+
tokens = {domain: token} if token and domain else None
|
|
81
|
+
return WAPClient(
|
|
82
|
+
agent_key=os.environ.get("WAP_AGENT_KEY") or None,
|
|
83
|
+
allow_insecure=insecure,
|
|
84
|
+
pinned_keys=pinned,
|
|
85
|
+
auth_tokens=tokens,
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fail(exc: Exception) -> None:
|
|
91
|
+
title = type(exc).__name__
|
|
92
|
+
body = Text(str(exc))
|
|
93
|
+
if isinstance(exc, ProtocolError) and exc.details:
|
|
94
|
+
body.append("\n\n")
|
|
95
|
+
body.append(json.dumps(exc.details, indent=2, default=str))
|
|
96
|
+
if isinstance(exc, SchemaValidationError):
|
|
97
|
+
body.append("\n\n" + "\n".join(f"• {e}" for e in exc.errors))
|
|
98
|
+
err_console.print(Panel(body, title=f"[bold red]{title}", border_style="red"))
|
|
99
|
+
raise typer.Exit(code=1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_data(data: str | None) -> dict[str, Any] | None:
|
|
103
|
+
if data is None:
|
|
104
|
+
return None
|
|
105
|
+
if data.startswith("@"):
|
|
106
|
+
data = Path(data[1:]).read_text(encoding="utf-8")
|
|
107
|
+
try:
|
|
108
|
+
value = json.loads(data)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
raise typer.BadParameter(f"--data must be JSON: {exc}") from exc
|
|
111
|
+
if not isinstance(value, dict):
|
|
112
|
+
raise typer.BadParameter("--data must be a JSON object")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ts(value: float | None) -> str:
|
|
117
|
+
if value is None:
|
|
118
|
+
return "—"
|
|
119
|
+
return datetime.fromtimestamp(value, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _manifest_table(manifest: AgentManifest) -> Table:
|
|
123
|
+
table = Table.grid(padding=(0, 2))
|
|
124
|
+
table.add_column(style="bold cyan", no_wrap=True)
|
|
125
|
+
table.add_column()
|
|
126
|
+
table.add_row("Name", manifest.name)
|
|
127
|
+
table.add_row("Domain", manifest.domain)
|
|
128
|
+
table.add_row("Description", manifest.description or "—")
|
|
129
|
+
table.add_row("WAP version", manifest.wap_version)
|
|
130
|
+
table.add_row("Public key", manifest.public_key)
|
|
131
|
+
table.add_row("Fingerprint", fingerprint(manifest.public_key))
|
|
132
|
+
table.add_row("Interaction URL", manifest.interaction_url)
|
|
133
|
+
table.add_row(
|
|
134
|
+
"Proof-of-work",
|
|
135
|
+
f"required (difficulty {manifest.pow_difficulty})" if manifest.pow_required else "not required",
|
|
136
|
+
)
|
|
137
|
+
table.add_row("Rate limit", json.dumps(manifest.rate_limit_policy))
|
|
138
|
+
table.add_row("Issued / expires", f"{_ts(manifest.issued_at)} → {_ts(manifest.expires_at)}")
|
|
139
|
+
return table
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def ask(
|
|
144
|
+
domain: Annotated[str, typer.Argument(help="Domain or URL, e.g. bakery.example or localhost:8000.")],
|
|
145
|
+
query: Annotated[str, typer.Argument(help="Free-text request for the business agent.")] = "",
|
|
146
|
+
capability: Annotated[
|
|
147
|
+
str | None, typer.Option("--capability", "-c", help="Invoke a specific capability id.")
|
|
148
|
+
] = None,
|
|
149
|
+
data: Annotated[
|
|
150
|
+
str | None, typer.Option("--data", "-d", help="JSON payload (or @file.json) for the capability.")
|
|
151
|
+
] = None,
|
|
152
|
+
session: Annotated[str | None, typer.Option("--session", "-s", help="Continue an existing session id.")] = None,
|
|
153
|
+
token: Annotated[
|
|
154
|
+
str | None, typer.Option("--token", envvar="WAP_TOKEN", help="Bearer token for auth-gated capabilities.")
|
|
155
|
+
] = None,
|
|
156
|
+
no_stream: Annotated[bool, typer.Option("--no-stream", help="Request a single JSON reply instead of SSE.")] = False,
|
|
157
|
+
as_json: Annotated[bool, typer.Option("--json", help="Print the verified result as JSON only.")] = False,
|
|
158
|
+
insecure: Annotated[bool, typer.Option("--insecure", help="Allow plain HTTP for non-loopback hosts.")] = False,
|
|
159
|
+
pin: Annotated[
|
|
160
|
+
list[str] | None, typer.Option("--pin", help="Pin a key: domain=<hex public key>. Repeatable.")
|
|
161
|
+
] = None,
|
|
162
|
+
timeout: Annotated[float, typer.Option("--timeout", help="Request timeout in seconds.")] = 30.0,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Send a single query to a domain's business agent and show the signed reply."""
|
|
165
|
+
payload = _parse_data(data)
|
|
166
|
+
if not query and not capability:
|
|
167
|
+
raise typer.BadParameter("provide a QUERY or --capability")
|
|
168
|
+
|
|
169
|
+
async def run() -> None:
|
|
170
|
+
async with _client(insecure, pin, token, domain, timeout) as client:
|
|
171
|
+
manifest = await client.discover(domain)
|
|
172
|
+
if not as_json:
|
|
173
|
+
console.print(
|
|
174
|
+
f"[dim]→ {manifest.name} ({manifest.domain}) · key {fingerprint(manifest.public_key)}"
|
|
175
|
+
+ (f" · solving PoW d={manifest.pow_difficulty}" if manifest.pow_required else "")
|
|
176
|
+
+ "[/dim]"
|
|
177
|
+
)
|
|
178
|
+
final = None
|
|
179
|
+
request = None
|
|
180
|
+
data_events: list[dict[str, Any]] = []
|
|
181
|
+
async for event in client.query(
|
|
182
|
+
domain, query, capability, payload, session_id=session, stream=not no_stream
|
|
183
|
+
):
|
|
184
|
+
if event.type is StreamEventType.TOKEN and not as_json:
|
|
185
|
+
console.print(event.text or "", end="", soft_wrap=True, highlight=False, markup=False)
|
|
186
|
+
elif event.type is StreamEventType.DATA and event.data:
|
|
187
|
+
data_events.append(event.data)
|
|
188
|
+
elif event.type is StreamEventType.MESSAGE:
|
|
189
|
+
final, request = event.message, event.request
|
|
190
|
+
if final is None:
|
|
191
|
+
raise ProtocolError("invalid_response", "reply stream ended without a final message")
|
|
192
|
+
if as_json:
|
|
193
|
+
console.print_json(
|
|
194
|
+
json.dumps(
|
|
195
|
+
{
|
|
196
|
+
"domain": manifest.domain,
|
|
197
|
+
"session_id": final.session_id,
|
|
198
|
+
"capability_id": final.capability_id,
|
|
199
|
+
"text": final.content,
|
|
200
|
+
"structured_data": final.structured_data,
|
|
201
|
+
"verified": True,
|
|
202
|
+
"pow_solved": bool(request and request.pow_nonce),
|
|
203
|
+
"message_id": final.message_id,
|
|
204
|
+
"signature": final.signature,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
console.print()
|
|
210
|
+
if final.structured_data:
|
|
211
|
+
console.print(
|
|
212
|
+
Panel(JSON.from_data(final.structured_data), title="structured_data", border_style="cyan")
|
|
213
|
+
)
|
|
214
|
+
console.print(
|
|
215
|
+
f"[green]✔ reply signature verified[/green] [dim]session={final.session_id} "
|
|
216
|
+
f"message={final.message_id}[/dim]"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
asyncio.run(run())
|
|
221
|
+
except (WAPError, ValueError) as exc:
|
|
222
|
+
_fail(exc)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def inspect(
|
|
227
|
+
domain: Annotated[str, typer.Argument(help="Domain or URL to inspect.")],
|
|
228
|
+
raw: Annotated[bool, typer.Option("--raw", help="Print the raw manifest JSON.")] = False,
|
|
229
|
+
insecure: Annotated[bool, typer.Option("--insecure", help="Allow plain HTTP for non-loopback hosts.")] = False,
|
|
230
|
+
timeout: Annotated[float, typer.Option("--timeout", help="Request timeout in seconds.")] = 30.0,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Pretty-print a site's verified wap.json manifest and capability schemas."""
|
|
233
|
+
|
|
234
|
+
async def run() -> None:
|
|
235
|
+
async with _client(insecure, None, None, None, timeout) as client:
|
|
236
|
+
manifest = await client.discover(domain)
|
|
237
|
+
if raw:
|
|
238
|
+
console.print_json(manifest.model_dump_json())
|
|
239
|
+
return
|
|
240
|
+
console.print(Panel(_manifest_table(manifest), title="[bold]WAP manifest ✔ verified", border_style="green"))
|
|
241
|
+
caps = Table(title="Capabilities", show_lines=True, expand=True)
|
|
242
|
+
caps.add_column("id", style="bold magenta", no_wrap=True)
|
|
243
|
+
caps.add_column("description")
|
|
244
|
+
caps.add_column("input", overflow="fold")
|
|
245
|
+
caps.add_column("auth", justify="center")
|
|
246
|
+
for cap in manifest.capabilities:
|
|
247
|
+
props = cap.input_schema.get("properties", {})
|
|
248
|
+
required = set(cap.input_schema.get("required", []))
|
|
249
|
+
params = (
|
|
250
|
+
"\n".join(
|
|
251
|
+
f"{'*' if name in required else ' '}{name}: {spec.get('type', spec.get('$ref', 'any'))}"
|
|
252
|
+
for name, spec in props.items()
|
|
253
|
+
)
|
|
254
|
+
or "—"
|
|
255
|
+
)
|
|
256
|
+
caps.add_row(cap.id, cap.description or cap.name, params, "🔒" if cap.requires_auth else "")
|
|
257
|
+
console.print(caps)
|
|
258
|
+
for cap in manifest.capabilities:
|
|
259
|
+
console.print(Panel(JSON.from_data(cap.input_schema), title=f"{cap.id} · input_schema", border_style="dim"))
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
asyncio.run(run())
|
|
263
|
+
except (WAPError, ValueError) as exc:
|
|
264
|
+
_fail(exc)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.command()
|
|
268
|
+
def verify(
|
|
269
|
+
domain: Annotated[str, typer.Argument(help="Domain or URL to verify.")],
|
|
270
|
+
expect_key: Annotated[
|
|
271
|
+
str | None, typer.Option("--expect-key", help="Fail unless the manifest uses this public key.")
|
|
272
|
+
] = None,
|
|
273
|
+
insecure: Annotated[bool, typer.Option("--insecure", help="Allow plain HTTP for non-loopback hosts.")] = False,
|
|
274
|
+
timeout: Annotated[float, typer.Option("--timeout", help="Request timeout in seconds.")] = 30.0,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Fetch a manifest and report each authenticity check."""
|
|
277
|
+
|
|
278
|
+
async def run() -> None:
|
|
279
|
+
pins = [f"{domain.split('://')[-1].split('/')[0]}={expect_key}"] if expect_key else None
|
|
280
|
+
async with _client(insecure, pins, None, None, timeout) as client:
|
|
281
|
+
resolved = await client.resolver.resolve_detailed(domain, force_refresh=True)
|
|
282
|
+
manifest = resolved.manifest
|
|
283
|
+
checks = Table(title=f"Verification of {manifest.domain}", show_header=True)
|
|
284
|
+
checks.add_column("check")
|
|
285
|
+
checks.add_column("result")
|
|
286
|
+
checks.add_row("Schema (WAP/1.0 AgentManifest)", "[green]pass")
|
|
287
|
+
checks.add_row("Domain binding", f"[green]pass[/green] ({manifest.domain})")
|
|
288
|
+
checks.add_row("Embedded Ed25519 signature", "[green]pass")
|
|
289
|
+
checks.add_row(
|
|
290
|
+
"X-WAP-Signature header",
|
|
291
|
+
"[green]pass" if resolved.header_signature_verified else "[yellow]absent",
|
|
292
|
+
)
|
|
293
|
+
checks.add_row("Endpoint origin binding", "[green]pass")
|
|
294
|
+
checks.add_row("Expiry", f"[green]valid until {_ts(manifest.expires_at)}")
|
|
295
|
+
checks.add_row("Pinned key", "[green]match" if expect_key else "[dim]not pinned")
|
|
296
|
+
checks.add_row("Key fingerprint", resolved.fingerprint)
|
|
297
|
+
console.print(checks)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
asyncio.run(run())
|
|
301
|
+
except (WAPError, ValueError) as exc:
|
|
302
|
+
_fail(exc)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command()
|
|
306
|
+
def keygen(
|
|
307
|
+
out: Annotated[
|
|
308
|
+
Path | None, typer.Option("--out", "-o", help="Write the private key to this file (mode 0600).")
|
|
309
|
+
] = None,
|
|
310
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit the key pair as JSON.")] = False,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Generate an Ed25519 key pair for signing a business manifest."""
|
|
313
|
+
pair = generate_keypair()
|
|
314
|
+
if out is not None:
|
|
315
|
+
if out.exists():
|
|
316
|
+
err_console.print(f"[red]{out} already exists; refusing to overwrite a key file.")
|
|
317
|
+
raise typer.Exit(code=1)
|
|
318
|
+
out.write_text(pair.private_key + "\n", encoding="utf-8")
|
|
319
|
+
out.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
320
|
+
if as_json:
|
|
321
|
+
document = {
|
|
322
|
+
"public_key": pair.public_key,
|
|
323
|
+
"fingerprint": pair.fingerprint,
|
|
324
|
+
"dns_txt_record": txt_record_for(pair.public_key),
|
|
325
|
+
}
|
|
326
|
+
if out is None:
|
|
327
|
+
document["private_key"] = pair.private_key
|
|
328
|
+
else:
|
|
329
|
+
document["private_key_file"] = str(out)
|
|
330
|
+
console.print_json(json.dumps(document))
|
|
331
|
+
return
|
|
332
|
+
table = Table.grid(padding=(0, 2))
|
|
333
|
+
table.add_column(style="bold cyan")
|
|
334
|
+
table.add_column()
|
|
335
|
+
if out is None:
|
|
336
|
+
table.add_row("Private key", pair.private_key)
|
|
337
|
+
else:
|
|
338
|
+
table.add_row("Private key", f"written to {out}")
|
|
339
|
+
table.add_row("Public key", pair.public_key)
|
|
340
|
+
table.add_row("Fingerprint", pair.fingerprint)
|
|
341
|
+
table.add_row("DNS TXT (optional)", f'_wap.<your-domain> TXT "{txt_record_for(pair.public_key)}"')
|
|
342
|
+
console.print(Panel(table, title="Ed25519 key pair", border_style="green"))
|
|
343
|
+
console.print("[dim]Keep the private key secret. Provide it via WAP_PRIVATE_KEY or WAPServer(private_key=...).")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command()
|
|
347
|
+
def serve(
|
|
348
|
+
target: Annotated[str, typer.Argument(help="ASGI application import path, e.g. examples.bakery_server:app")],
|
|
349
|
+
host: Annotated[str, typer.Option("--host", help="Interface to bind.")] = "127.0.0.1",
|
|
350
|
+
port: Annotated[int, typer.Option("--port", help="Port to bind.")] = 8000,
|
|
351
|
+
reload: Annotated[bool, typer.Option("--reload", help="Reload on code changes (development).")] = False,
|
|
352
|
+
app_dir: Annotated[Path, typer.Option("--app-dir", help="Directory added to sys.path before import.")] = Path("."),
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Run a WAP-enabled ASGI application with uvicorn."""
|
|
355
|
+
try:
|
|
356
|
+
import uvicorn
|
|
357
|
+
except ImportError as exc:
|
|
358
|
+
err_console.print('[red]uvicorn is not installed. Run: pip install "webagent-protocol[server]"')
|
|
359
|
+
raise typer.Exit(code=1) from exc
|
|
360
|
+
sys.path.insert(0, str(app_dir.resolve()))
|
|
361
|
+
uvicorn.run(target, host=host, port=port, reload=reload, app_dir=str(app_dir.resolve()))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def main() -> None:
|
|
365
|
+
app()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
main()
|
wap/client/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Consumer SDK: discover and negotiate with WAP business agents."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AuthRequired,
|
|
5
|
+
CapabilityNotFound,
|
|
6
|
+
ConfirmationDeclined,
|
|
7
|
+
ConversationLimitReached,
|
|
8
|
+
ConversationStopped,
|
|
9
|
+
InsecureTransport,
|
|
10
|
+
LoopDetected,
|
|
11
|
+
ManifestNotFound,
|
|
12
|
+
ProofOfWorkFailed,
|
|
13
|
+
ProtocolError,
|
|
14
|
+
RateLimited,
|
|
15
|
+
SchemaValidationError,
|
|
16
|
+
VerificationFailed,
|
|
17
|
+
WAPError,
|
|
18
|
+
)
|
|
19
|
+
from .resolver import ManifestResolver, ResolvedManifest, Target, parse_target
|
|
20
|
+
from .session import ConfirmationRequest, InteractionResult, StreamEvent, WAPClient, WAPSession, validate_payload
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AuthRequired",
|
|
24
|
+
"CapabilityNotFound",
|
|
25
|
+
"ConfirmationDeclined",
|
|
26
|
+
"ConfirmationRequest",
|
|
27
|
+
"ConversationLimitReached",
|
|
28
|
+
"ConversationStopped",
|
|
29
|
+
"InsecureTransport",
|
|
30
|
+
"LoopDetected",
|
|
31
|
+
"InteractionResult",
|
|
32
|
+
"ManifestNotFound",
|
|
33
|
+
"ManifestResolver",
|
|
34
|
+
"ProofOfWorkFailed",
|
|
35
|
+
"ProtocolError",
|
|
36
|
+
"RateLimited",
|
|
37
|
+
"ResolvedManifest",
|
|
38
|
+
"SchemaValidationError",
|
|
39
|
+
"StreamEvent",
|
|
40
|
+
"Target",
|
|
41
|
+
"VerificationFailed",
|
|
42
|
+
"WAPClient",
|
|
43
|
+
"WAPError",
|
|
44
|
+
"WAPSession",
|
|
45
|
+
"parse_target",
|
|
46
|
+
"validate_payload",
|
|
47
|
+
]
|
wap/client/dnskey.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Optional DNS anchoring of a domain's WAP key.
|
|
2
|
+
|
|
3
|
+
A domain can publish its key (or keys, during a rotation) in DNS::
|
|
4
|
+
|
|
5
|
+
_wap.bakery.example. TXT "v=wap1; k=8c2fcce14c053b08152248cd13ebab41e783abae46d51140f6954d22d7f3a1b2"
|
|
6
|
+
|
|
7
|
+
A client configured with ``dns_key_policy="if-present"`` rejects a manifest whose
|
|
8
|
+
key is not among the published ones; with ``"require"`` it also rejects domains
|
|
9
|
+
that publish none. An attacker who compromises only the web server (or its TLS
|
|
10
|
+
certificate) then cannot substitute a key without also controlling DNS; with
|
|
11
|
+
DNSSEC this becomes a strong independent anchor.
|
|
12
|
+
|
|
13
|
+
Lookups use ``dnspython`` (``pip install "webagent-protocol[dns]"``) unless a
|
|
14
|
+
custom async ``txt_resolver`` is supplied (e.g. a DNS-over-HTTPS client).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
|
|
22
|
+
from ..spec.crypto import fingerprint
|
|
23
|
+
from ..spec.models import AgentManifest, authority_host
|
|
24
|
+
from .exceptions import VerificationFailed
|
|
25
|
+
|
|
26
|
+
TxtResolver = Callable[[str], Awaitable[list[str]]]
|
|
27
|
+
|
|
28
|
+
_KEY_RE = re.compile(r"(?:^|;)\s*k\s*=\s*([0-9a-fA-F]{64})\s*(?:;|$)")
|
|
29
|
+
_VERSION_RE = re.compile(r"^\s*v\s*=\s*wap1\s*(?:;|$)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def record_name(domain: str) -> str:
|
|
33
|
+
return f"_wap.{authority_host(domain)}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def txt_record_for(public_key: str) -> str:
|
|
37
|
+
"""The TXT record value a domain publishes for ``public_key``."""
|
|
38
|
+
return f"v=wap1; k={public_key.lower()}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_keys(records: list[str]) -> set[str]:
|
|
42
|
+
keys: set[str] = set()
|
|
43
|
+
for record in records:
|
|
44
|
+
if not _VERSION_RE.match(record):
|
|
45
|
+
continue
|
|
46
|
+
match = _KEY_RE.search(record)
|
|
47
|
+
if match:
|
|
48
|
+
keys.add(match.group(1).lower())
|
|
49
|
+
return keys
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def dnspython_txt(name: str) -> list[str]:
|
|
53
|
+
try:
|
|
54
|
+
import dns.asyncresolver
|
|
55
|
+
import dns.exception
|
|
56
|
+
import dns.resolver
|
|
57
|
+
except ImportError as exc: # pragma: no cover - depends on the optional extra
|
|
58
|
+
raise VerificationFailed(name, 'DNS key checks need dnspython: pip install "webagent-protocol[dns]"') from exc
|
|
59
|
+
try:
|
|
60
|
+
answer = await dns.asyncresolver.resolve(name, "TXT")
|
|
61
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
|
62
|
+
return []
|
|
63
|
+
except dns.exception.DNSException as exc:
|
|
64
|
+
raise VerificationFailed(name, f"DNS lookup failed: {exc}") from exc
|
|
65
|
+
return [b"".join(r.strings).decode("utf-8", "replace") for r in answer]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def check_dns_key(manifest: AgentManifest, *, required: bool, txt_resolver: TxtResolver | None = None) -> None:
|
|
69
|
+
name = record_name(manifest.domain)
|
|
70
|
+
keys = parse_keys(await (txt_resolver or dnspython_txt)(name))
|
|
71
|
+
if not keys:
|
|
72
|
+
if required:
|
|
73
|
+
raise VerificationFailed(manifest.domain, f"no WAP key published at {name} (DNS key policy: require)")
|
|
74
|
+
return
|
|
75
|
+
if manifest.public_key not in keys:
|
|
76
|
+
raise VerificationFailed(
|
|
77
|
+
manifest.domain,
|
|
78
|
+
f"manifest key {fingerprint(manifest.public_key)} is not among the keys published at {name}",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["TxtResolver", "check_dns_key", "dnspython_txt", "parse_keys", "record_name", "txt_record_for"]
|