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 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
@@ -0,0 +1,5 @@
1
+ """The ``wap`` command-line interface (entry point: ``wap.cli.main:app``)."""
2
+
3
+ from .main import app
4
+
5
+ __all__ = ["app"]
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"]