nais-sdk 1.0.0__tar.gz

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.
nais_sdk-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NAIS Standard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: nais-sdk
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the Network Agent Identity Standard (NAIS). Resolve, validate, and verify the signatures of NAIS-compliant agent cards.
5
+ License: MIT
6
+ Project-URL: Homepage, https://nais.id
7
+ Project-URL: Documentation, https://nais.id/sdks
8
+ Project-URL: Repository, https://github.com/nais-standard/clients
9
+ Project-URL: Bug Tracker, https://github.com/nais-standard/clients/issues
10
+ Keywords: nais,agent,identity,ai-agent,dns,resolver,mcp,web3,ed25519,signature
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: Name Service (DNS)
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: dnspython>=2.0
27
+ Requires-Dist: cryptography>=41
28
+ Provides-Extra: pynacl
29
+ Requires-Dist: PyNaCl>=1.5; extra == "pynacl"
30
+ Dynamic: license-file
31
+
32
+ # nais-sdk
33
+
34
+ Python SDK for the [Network Agent Identity Standard (NAIS)](https://nais.id).
35
+
36
+ Resolve and validate NAIS-compliant agent domains. Requires Python 3.8+ and depends on `dnspython` (DNS lookups) and `cryptography` (Ed25519 signature verification; PyNaCl is also supported). `pip install nais-sdk` pulls both. The SDK resolves directly: it reads the `_agent.<domain>` DNS TXT record, fetches the signed card over HTTPS (HTTPS-only, no cross-host redirects, 1 MiB cap), and verifies the card's mandatory Ed25519 signature against the DNS `k=` key. Server-side only — browsers cannot perform DNS lookups.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install nais-sdk
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### resolve(domain)
47
+
48
+ Resolves a domain directly via DNS and HTTPS and returns a structured result dictionary:
49
+
50
+ - `ok` — overall success flag.
51
+ - `domain` — the normalized domain.
52
+ - `agent_host` — the host serving the card.
53
+ - `dns` — `{records, parsed}`, where `parsed` exposes `v`, `manifest`, and `k`.
54
+ - `manifest_url` — the URL the card was fetched from.
55
+ - `card` — the decoded `agent.json`.
56
+ - `signature` — `{present, verified, kid, alg, reason}`.
57
+ - `validation` — `{valid, errors, warnings}`.
58
+
59
+ ```python
60
+ from nais_sdk import resolve
61
+
62
+ r = resolve("weatheragent.nais.id")
63
+ if r["signature"]["verified"]:
64
+ print(r["card"]["mcp"])
65
+ # https://weatheragent.nais.id/mcp
66
+
67
+ print(r["dns"]["parsed"]["k"])
68
+ # ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ
69
+ ```
70
+
71
+ `resolve()` raises on an invalid domain, when no NAIS TXT record is found, or when the card fetch fails. It does **not** raise on a bad signature — that surfaces as `signature["verified"] is False` and `validation["valid"] is False`.
72
+
73
+ ### validate(domain)
74
+
75
+ Returns a flattened summary dictionary. Best for quick validation checks before using an agent.
76
+
77
+ ```python
78
+ from nais_sdk import validate
79
+
80
+ summary = validate("weatheragent.nais.id")
81
+ print(summary)
82
+ ```
83
+
84
+ Example output:
85
+
86
+ ```python
87
+ {
88
+ "valid": True,
89
+ "domain": "weatheragent.nais.id",
90
+ "version": "nais1",
91
+ "manifest_url": "https://weatheragent.nais.id/.well-known/agent.json",
92
+ "mcp_endpoint": "https://weatheragent.nais.id/mcp",
93
+ "has_mcp": True,
94
+ "has_card": True,
95
+ "signature_verified": True,
96
+ "signature_reason": None,
97
+ "key": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
98
+ "kid": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
99
+ "auth": ["wallet"],
100
+ "payments": ["x402"],
101
+ "pay_to": ["0x742d35Cc6634C0532925a3b8D4C9B7F1A2e3d4E5"],
102
+ "tags": ["forecast", "current_weather", "alerts"],
103
+ "warnings": [],
104
+ "errors": []
105
+ }
106
+ ```
107
+
108
+ `valid` is `True` only when the card resolved, the schema validates, **and** the signature verifies.
109
+
110
+ ```python
111
+ from nais_sdk import validate
112
+
113
+ summary = validate("weatheragent.nais.id")
114
+ if summary["signature_verified"]:
115
+ print(summary["tags"]) # ['forecast', 'current_weather', 'alerts']
116
+ print(summary["pay_to"]) # pay_to only populated for verified cards
117
+ ```
118
+
119
+ ### verify_card / canonicalize
120
+
121
+ The SDK also exports `verify_card(card, dns_key)` and `canonicalize(value)` for verifying a card you already hold against a DNS `k=` key. It also exports `parse_nais_txt` and `normalize_domain`.
122
+
123
+ ```python
124
+ from nais_sdk import verify_card, canonicalize
125
+
126
+ result = verify_card(card, "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ")
127
+ print(result["verified"], result["reason"])
128
+ ```
129
+
130
+ ### Error handling
131
+
132
+ ```python
133
+ from nais_sdk import resolve, ResolutionError
134
+
135
+ try:
136
+ result = resolve("not-a-real-agent.example.com")
137
+ except ResolutionError as e:
138
+ print(f"Failed to resolve {e.domain}: {e}")
139
+ ```
140
+
141
+ ### Timeout
142
+
143
+ Both `resolve()` and `validate()` accept an optional `timeout` argument (default: 10 seconds):
144
+
145
+ ```python
146
+ result = resolve("weatheragent.nais.id", timeout=5)
147
+ ```
148
+
149
+ ### Testing without DNS or network
150
+
151
+ `resolve()` and `validate()` accept injected `lookup_txt` and `fetch_card` callables as keyword arguments, so resolution can be tested offline:
152
+
153
+ ```python
154
+ result = resolve(
155
+ "weatheragent.nais.id",
156
+ lookup_txt=lambda name: ["v=nais1; manifest=https://weatheragent.nais.id/.well-known/agent.json; k=ed25519:..."],
157
+ fetch_card=lambda url: {...}, # decoded agent.json
158
+ )
159
+ ```
160
+
161
+ ## Notes
162
+
163
+ - Requires Python 3.8+. Depends on `dnspython` (DNS) and `cryptography` (signatures; PyNaCl also supported); `pip install nais-sdk` installs both.
164
+ - Resolution is performed locally and directly: DNS TXT lookup, HTTPS card fetch, and Ed25519 signature verification all happen in-process. There is no central resolver.
165
+ - The card fetch is HTTPS-only, refuses cross-host redirects, and caps responses at 1 MiB.
166
+ - Server-side only: browsers cannot perform DNS lookups.
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,139 @@
1
+ # nais-sdk
2
+
3
+ Python SDK for the [Network Agent Identity Standard (NAIS)](https://nais.id).
4
+
5
+ Resolve and validate NAIS-compliant agent domains. Requires Python 3.8+ and depends on `dnspython` (DNS lookups) and `cryptography` (Ed25519 signature verification; PyNaCl is also supported). `pip install nais-sdk` pulls both. The SDK resolves directly: it reads the `_agent.<domain>` DNS TXT record, fetches the signed card over HTTPS (HTTPS-only, no cross-host redirects, 1 MiB cap), and verifies the card's mandatory Ed25519 signature against the DNS `k=` key. Server-side only — browsers cannot perform DNS lookups.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install nais-sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### resolve(domain)
16
+
17
+ Resolves a domain directly via DNS and HTTPS and returns a structured result dictionary:
18
+
19
+ - `ok` — overall success flag.
20
+ - `domain` — the normalized domain.
21
+ - `agent_host` — the host serving the card.
22
+ - `dns` — `{records, parsed}`, where `parsed` exposes `v`, `manifest`, and `k`.
23
+ - `manifest_url` — the URL the card was fetched from.
24
+ - `card` — the decoded `agent.json`.
25
+ - `signature` — `{present, verified, kid, alg, reason}`.
26
+ - `validation` — `{valid, errors, warnings}`.
27
+
28
+ ```python
29
+ from nais_sdk import resolve
30
+
31
+ r = resolve("weatheragent.nais.id")
32
+ if r["signature"]["verified"]:
33
+ print(r["card"]["mcp"])
34
+ # https://weatheragent.nais.id/mcp
35
+
36
+ print(r["dns"]["parsed"]["k"])
37
+ # ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ
38
+ ```
39
+
40
+ `resolve()` raises on an invalid domain, when no NAIS TXT record is found, or when the card fetch fails. It does **not** raise on a bad signature — that surfaces as `signature["verified"] is False` and `validation["valid"] is False`.
41
+
42
+ ### validate(domain)
43
+
44
+ Returns a flattened summary dictionary. Best for quick validation checks before using an agent.
45
+
46
+ ```python
47
+ from nais_sdk import validate
48
+
49
+ summary = validate("weatheragent.nais.id")
50
+ print(summary)
51
+ ```
52
+
53
+ Example output:
54
+
55
+ ```python
56
+ {
57
+ "valid": True,
58
+ "domain": "weatheragent.nais.id",
59
+ "version": "nais1",
60
+ "manifest_url": "https://weatheragent.nais.id/.well-known/agent.json",
61
+ "mcp_endpoint": "https://weatheragent.nais.id/mcp",
62
+ "has_mcp": True,
63
+ "has_card": True,
64
+ "signature_verified": True,
65
+ "signature_reason": None,
66
+ "key": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
67
+ "kid": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
68
+ "auth": ["wallet"],
69
+ "payments": ["x402"],
70
+ "pay_to": ["0x742d35Cc6634C0532925a3b8D4C9B7F1A2e3d4E5"],
71
+ "tags": ["forecast", "current_weather", "alerts"],
72
+ "warnings": [],
73
+ "errors": []
74
+ }
75
+ ```
76
+
77
+ `valid` is `True` only when the card resolved, the schema validates, **and** the signature verifies.
78
+
79
+ ```python
80
+ from nais_sdk import validate
81
+
82
+ summary = validate("weatheragent.nais.id")
83
+ if summary["signature_verified"]:
84
+ print(summary["tags"]) # ['forecast', 'current_weather', 'alerts']
85
+ print(summary["pay_to"]) # pay_to only populated for verified cards
86
+ ```
87
+
88
+ ### verify_card / canonicalize
89
+
90
+ The SDK also exports `verify_card(card, dns_key)` and `canonicalize(value)` for verifying a card you already hold against a DNS `k=` key. It also exports `parse_nais_txt` and `normalize_domain`.
91
+
92
+ ```python
93
+ from nais_sdk import verify_card, canonicalize
94
+
95
+ result = verify_card(card, "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ")
96
+ print(result["verified"], result["reason"])
97
+ ```
98
+
99
+ ### Error handling
100
+
101
+ ```python
102
+ from nais_sdk import resolve, ResolutionError
103
+
104
+ try:
105
+ result = resolve("not-a-real-agent.example.com")
106
+ except ResolutionError as e:
107
+ print(f"Failed to resolve {e.domain}: {e}")
108
+ ```
109
+
110
+ ### Timeout
111
+
112
+ Both `resolve()` and `validate()` accept an optional `timeout` argument (default: 10 seconds):
113
+
114
+ ```python
115
+ result = resolve("weatheragent.nais.id", timeout=5)
116
+ ```
117
+
118
+ ### Testing without DNS or network
119
+
120
+ `resolve()` and `validate()` accept injected `lookup_txt` and `fetch_card` callables as keyword arguments, so resolution can be tested offline:
121
+
122
+ ```python
123
+ result = resolve(
124
+ "weatheragent.nais.id",
125
+ lookup_txt=lambda name: ["v=nais1; manifest=https://weatheragent.nais.id/.well-known/agent.json; k=ed25519:..."],
126
+ fetch_card=lambda url: {...}, # decoded agent.json
127
+ )
128
+ ```
129
+
130
+ ## Notes
131
+
132
+ - Requires Python 3.8+. Depends on `dnspython` (DNS) and `cryptography` (signatures; PyNaCl also supported); `pip install nais-sdk` installs both.
133
+ - Resolution is performed locally and directly: DNS TXT lookup, HTTPS card fetch, and Ed25519 signature verification all happen in-process. There is no central resolver.
134
+ - The card fetch is HTTPS-only, refuses cross-host redirects, and caps responses at 1 MiB.
135
+ - Server-side only: browsers cannot perform DNS lookups.
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,443 @@
1
+ """
2
+ nais_sdk — Python SDK for the Network Agent Identity Standard.
3
+
4
+ Resolution is fully decentralized: the SDK reads the agent's ``_agent`` DNS TXT
5
+ record itself, fetches the signed card over HTTPS, and verifies the signature
6
+ against the DNS-published key. There is no central resolver in the trust or
7
+ availability path — trust travels with the signed card.
8
+
9
+ Requires Python 3.8+. DNS lookups use ``dnspython``; signature verification uses
10
+ ``cryptography`` (preferred) or ``PyNaCl``.
11
+ """
12
+
13
+ import base64
14
+ import hashlib
15
+ import json
16
+ import urllib.request
17
+ import urllib.parse
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+ __version__ = "1.0.0"
21
+ __all__ = [
22
+ "resolve",
23
+ "validate",
24
+ "verify_card",
25
+ "canonicalize",
26
+ "normalize_domain",
27
+ "parse_nais_txt",
28
+ "NAISError",
29
+ "ResolutionError",
30
+ ]
31
+
32
+ MAX_CARD_BYTES = 1024 * 1024 # 1 MiB
33
+
34
+
35
+ class NAISError(Exception):
36
+ """Base exception for all NAIS SDK errors."""
37
+
38
+
39
+ class ResolutionError(NAISError):
40
+ """Raised when a domain cannot be resolved.
41
+
42
+ Attributes:
43
+ domain (str): The domain that failed to resolve.
44
+ """
45
+
46
+ def __init__(self, message: str, domain: str) -> None:
47
+ super().__init__(message)
48
+ self.domain: str = domain
49
+
50
+ def __repr__(self) -> str:
51
+ return f"ResolutionError(domain={self.domain!r}, message={str(self)!r})"
52
+
53
+
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+ # Signature verification — detached EdDSA JWS over the canonical card
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ def canonicalize(value: Any) -> str:
59
+ """Return the NAIS canonical JSON byte string (a subset of RFC 8785 / JCS).
60
+
61
+ Object keys sorted ascending by code point, no whitespace, "/" and non-ASCII
62
+ left unescaped, integers emitted as integers. Cards MUST NOT contain floats.
63
+ """
64
+ if isinstance(value, bool):
65
+ return "true" if value else "false"
66
+ if isinstance(value, dict):
67
+ return "{" + ",".join(
68
+ json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k])
69
+ for k in sorted(value.keys())
70
+ ) + "}"
71
+ if isinstance(value, (list, tuple)):
72
+ return "[" + ",".join(canonicalize(v) for v in value) + "]"
73
+ if isinstance(value, int):
74
+ return str(value)
75
+ if isinstance(value, float):
76
+ raise ValueError("NAIS cards must not contain floating-point numbers")
77
+ if value is None:
78
+ return "null"
79
+ return json.dumps(value, ensure_ascii=False)
80
+
81
+
82
+ def _b64url_decode(s: str) -> bytes:
83
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
84
+
85
+
86
+ def _b64url_encode(b: bytes) -> str:
87
+ return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
88
+
89
+
90
+ def _ed25519_verify(public_key: bytes, signature: bytes, message: bytes) -> bool:
91
+ """Verify an Ed25519 signature using cryptography or PyNaCl, whichever exists."""
92
+ try:
93
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
94
+ from cryptography.exceptions import InvalidSignature
95
+
96
+ try:
97
+ Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message)
98
+ return True
99
+ except InvalidSignature:
100
+ return False
101
+ except ImportError:
102
+ pass
103
+
104
+ try:
105
+ import nacl.signing
106
+ import nacl.exceptions
107
+
108
+ try:
109
+ nacl.signing.VerifyKey(public_key).verify(message, signature)
110
+ return True
111
+ except nacl.exceptions.BadSignatureError:
112
+ return False
113
+ except ImportError:
114
+ raise NAISError(
115
+ "Signature verification requires the 'cryptography' or 'PyNaCl' package"
116
+ )
117
+
118
+
119
+ def verify_card(card: Dict[str, Any], dns_key: Optional[str]) -> Dict[str, Any]:
120
+ """Verify a NAIS card's detached EdDSA JWS against the DNS-published key.
121
+
122
+ The card is authentic only when it carries a valid Ed25519 signature over its
123
+ own canonical body AND ``signature.kid`` equals the ``k=`` fingerprint from
124
+ the ``_agent`` DNS record. Forging it needs both DNS control and the key.
125
+
126
+ Returns a dict: ``present``, ``verified``, ``kid``, ``alg``, ``reason``.
127
+ """
128
+ out: Dict[str, Any] = {"present": False, "verified": False, "kid": None, "alg": None, "reason": None}
129
+ sig = card.get("signature") if isinstance(card, dict) else None
130
+ if not isinstance(sig, dict):
131
+ out["reason"] = "no signature object"
132
+ return out
133
+
134
+ out["present"] = True
135
+ out["kid"] = sig.get("kid")
136
+ out["alg"] = sig.get("alg")
137
+
138
+ if sig.get("alg") != "EdDSA":
139
+ out["reason"] = "unsupported alg (expected EdDSA)"
140
+ return out
141
+ if not sig.get("kid") or not sig.get("jws"):
142
+ out["reason"] = "signature missing kid or jws"
143
+ return out
144
+ if not str(sig["kid"]).startswith("ed25519:"):
145
+ out["reason"] = "kid is not an ed25519 key"
146
+ return out
147
+ if not dns_key:
148
+ out["reason"] = "no k= key published in the _agent DNS record to anchor trust"
149
+ return out
150
+ if dns_key != sig["kid"]:
151
+ out["reason"] = "signature.kid does not match the DNS k= key"
152
+ return out
153
+
154
+ parts = str(sig["jws"]).split(".")
155
+ if len(parts) != 3 or parts[1] != "":
156
+ out["reason"] = "jws is not a detached compact JWS"
157
+ return out
158
+ protected_b64, _, sig_b64 = parts
159
+
160
+ body = {k: v for k, v in card.items() if k != "signature"}
161
+ try:
162
+ payload = canonicalize(body).encode("utf-8")
163
+ except ValueError as exc:
164
+ out["reason"] = "canonicalization failed: " + str(exc)
165
+ return out
166
+ signing_input = (protected_b64 + "." + _b64url_encode(payload)).encode("ascii")
167
+
168
+ public_key = _b64url_decode(str(sig["kid"])[len("ed25519:"):])
169
+ if len(public_key) != 32:
170
+ out["reason"] = "malformed ed25519 public key in kid"
171
+ return out
172
+
173
+ out["verified"] = _ed25519_verify(public_key, _b64url_decode(sig_b64), signing_input)
174
+ if not out["verified"]:
175
+ out["reason"] = "Ed25519 signature does not match the canonical card body"
176
+ return out
177
+
178
+
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ # Domain / DNS / card fetching
181
+ # ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ def normalize_domain(value: str) -> Optional[str]:
184
+ """Normalize input to a bare lowercase hostname, or None if invalid."""
185
+ if not value or not isinstance(value, str):
186
+ return None
187
+ s = value.strip()
188
+ if s.lower().startswith("http://"):
189
+ s = s[7:]
190
+ elif s.lower().startswith("https://"):
191
+ s = s[8:]
192
+ s = s.split("/", 1)[0].split("?", 1)[0]
193
+ if ":" in s:
194
+ s = s.rsplit(":", 1)[0]
195
+ s = s.lower().rstrip(".")
196
+ if not s or len(s) > 253:
197
+ return None
198
+ labels = s.split(".")
199
+ if len(labels) < 2:
200
+ return None
201
+ import re
202
+ for label in labels:
203
+ if len(label) > 63 or not re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", label):
204
+ return None
205
+ return s
206
+
207
+
208
+ def parse_nais_txt(raw: str) -> Dict[str, str]:
209
+ """Parse a NAIS TXT record into a key/value dict (semicolon-delimited)."""
210
+ out: Dict[str, str] = {}
211
+ for seg in str(raw).split(";"):
212
+ seg = seg.strip()
213
+ eq = seg.find("=")
214
+ if eq <= 0:
215
+ continue
216
+ out[seg[:eq].strip().lower()] = seg[eq + 1:].strip()
217
+ return out
218
+
219
+
220
+ def _default_lookup_txt(host: str) -> List[str]:
221
+ """Default DNS TXT lookup at _agent.<domain> using dnspython."""
222
+ try:
223
+ import dns.resolver
224
+ import dns.exception
225
+ except ImportError:
226
+ raise NAISError("DNS resolution requires the 'dnspython' package")
227
+
228
+ try:
229
+ answers = dns.resolver.resolve(host, "TXT")
230
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
231
+ return []
232
+ except dns.exception.DNSException as exc:
233
+ raise NAISError(f"DNS lookup error: {exc}")
234
+
235
+ records: List[str] = []
236
+ for rdata in answers:
237
+ chunks = getattr(rdata, "strings", None)
238
+ if chunks is not None:
239
+ records.append("".join(c.decode("utf-8", "replace") if isinstance(c, bytes) else c for c in chunks))
240
+ else:
241
+ records.append(str(rdata).strip('"'))
242
+ return records
243
+
244
+
245
+ def _default_fetch_card(url: str, timeout: int) -> Dict[str, Any]:
246
+ """Default card fetcher: HTTPS only, no cross-host redirect, size-capped."""
247
+ if not url.lower().startswith("https://"):
248
+ raise ValueError("card URL must use HTTPS")
249
+
250
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
251
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
252
+ final = resp.geturl()
253
+ if urllib.parse.urlparse(final).netloc != urllib.parse.urlparse(url).netloc:
254
+ raise ValueError("card URL redirected to a different host")
255
+ raw = resp.read(MAX_CARD_BYTES + 1)
256
+ if len(raw) > MAX_CARD_BYTES:
257
+ raise ValueError("card exceeds 1 MiB")
258
+ try:
259
+ data = json.loads(raw)
260
+ except json.JSONDecodeError as exc:
261
+ raise ValueError(f"card is not valid JSON: {exc}")
262
+ if not isinstance(data, dict):
263
+ raise ValueError("card is not a JSON object")
264
+ return data
265
+
266
+
267
+ def validate_card(card: Dict[str, Any], expected_domain: str, signature: Dict[str, Any]) -> Dict[str, Any]:
268
+ """Validate a decoded card against the NAIS 1.0 schema (structural + signature)."""
269
+ errors: List[str] = []
270
+ warnings: List[str] = []
271
+
272
+ if card.get("nais") is None:
273
+ errors.append('Missing required field: nais — expected "nais": "1.0"')
274
+ elif not str(card["nais"]).startswith("1."):
275
+ warnings.append(f'Unexpected nais version "{card["nais"]}" — this SDK implements 1.x')
276
+
277
+ if "cardVersion" not in card:
278
+ errors.append("Missing required field: cardVersion (integer)")
279
+ elif not isinstance(card["cardVersion"], int) or isinstance(card["cardVersion"], bool):
280
+ errors.append("Field cardVersion must be an integer")
281
+
282
+ if not card.get("updated"):
283
+ warnings.append("Missing recommended field: updated (ISO 8601 timestamp)")
284
+ if not card.get("name"):
285
+ errors.append("Missing required field: name")
286
+
287
+ if not card.get("domain"):
288
+ errors.append("Missing required field: domain")
289
+ elif str(card["domain"]).lower().rstrip(".") != expected_domain:
290
+ errors.append(f'Field domain "{card["domain"]}" does not match resolved domain "{expected_domain}"')
291
+
292
+ if not signature.get("present"):
293
+ errors.append("Missing required field: signature — every NAIS 1.0 card MUST carry a detached EdDSA JWS")
294
+ elif not signature.get("verified"):
295
+ errors.append("Signature verification failed: " + (signature.get("reason") or "unknown reason"))
296
+
297
+ if not card.get("mcp"):
298
+ warnings.append("No MCP endpoint declared (mcp)")
299
+ if "capabilities" in card:
300
+ warnings.append('Field capabilities is deprecated in NAIS 1.0 — use free-form "tags"')
301
+
302
+ payment = card.get("payment")
303
+ if isinstance(payment, dict):
304
+ if not payment.get("payTo"):
305
+ warnings.append("payment present but payment.payTo is empty")
306
+ elif not signature.get("verified"):
307
+ warnings.append("payment.payTo MUST NOT be used: the card signature is not verified")
308
+
309
+ snap = card.get("mcpSnapshot")
310
+ if isinstance(snap, dict) and isinstance(snap.get("tools"), list) and snap.get("toolsHash"):
311
+ computed = "sha256:" + hashlib.sha256(canonicalize(snap["tools"]).encode("utf-8")).hexdigest()
312
+ if computed != snap["toolsHash"]:
313
+ warnings.append("mcpSnapshot.toolsHash does not match the snapshot tools — snapshot may be stale or altered")
314
+
315
+ return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
316
+
317
+
318
+ # ─────────────────────────────────────────────────────────────────────────────
319
+ # Resolution
320
+ # ─────────────────────────────────────────────────────────────────────────────
321
+
322
+ def resolve(
323
+ domain: str,
324
+ timeout: int = 8,
325
+ lookup_txt: Optional[Callable[[str], List[str]]] = None,
326
+ fetch_card: Optional[Callable[[str], Dict[str, Any]]] = None,
327
+ ) -> Dict[str, Any]:
328
+ """Resolve a NAIS agent domain directly.
329
+
330
+ Reads the ``_agent`` DNS TXT record, fetches the signed card, and verifies
331
+ its signature against the DNS-published key.
332
+
333
+ Args:
334
+ domain: The agent domain, e.g. ``"weatheragent.nais.id"``.
335
+ timeout: HTTP timeout in seconds for the card fetch.
336
+ lookup_txt: Optional override for DNS lookup (testing).
337
+ fetch_card: Optional override for card fetching (testing).
338
+
339
+ Returns:
340
+ ``{ ok, domain, agent_host, dns, manifest_url, card, signature, validation }``
341
+
342
+ Raises:
343
+ ResolutionError: If the domain is invalid, has no NAIS record, or the
344
+ card cannot be fetched.
345
+ """
346
+ if not domain or not isinstance(domain, str) or not domain.strip():
347
+ raise ResolutionError("domain must be a non-empty string", str(domain))
348
+
349
+ normalized = normalize_domain(domain)
350
+ if normalized is None:
351
+ raise ResolutionError("Invalid domain — provide a bare hostname such as weatheragent.nais.id", domain)
352
+
353
+ do_lookup = lookup_txt or _default_lookup_txt
354
+ do_fetch = fetch_card or (lambda url: _default_fetch_card(url, timeout))
355
+ agent_host = "_agent." + normalized
356
+
357
+ try:
358
+ records = list(do_lookup(agent_host))
359
+ except NAISError:
360
+ raise
361
+ except Exception as exc: # noqa: BLE001
362
+ raise ResolutionError(f"DNS lookup failed for {agent_host}: {exc}", normalized)
363
+
364
+ nais_records = [r for r in records if "v=" in r]
365
+ if not nais_records:
366
+ raise ResolutionError(f"No NAIS TXT record found at {agent_host}", normalized)
367
+
368
+ parsed = parse_nais_txt(nais_records[0])
369
+ manifest_url = parsed.get("manifest") or f"https://{normalized}/.well-known/agent.json"
370
+
371
+ try:
372
+ card = do_fetch(manifest_url)
373
+ except Exception as exc: # noqa: BLE001
374
+ raise ResolutionError(f"Failed to fetch card from {manifest_url}: {exc}", normalized)
375
+ if not isinstance(card, dict):
376
+ raise ResolutionError(f"Card at {manifest_url} is not a JSON object", normalized)
377
+
378
+ signature = verify_card(card, parsed.get("k"))
379
+ validation = validate_card(card, normalized, signature)
380
+
381
+ return {
382
+ "ok": True,
383
+ "domain": normalized,
384
+ "agent_host": agent_host,
385
+ "dns": {"records": records, "parsed": parsed},
386
+ "manifest_url": manifest_url,
387
+ "card": card,
388
+ "signature": signature,
389
+ "validation": validation,
390
+ }
391
+
392
+
393
+ def validate(domain: str, timeout: int = 8, **kwargs: Any) -> Dict[str, Any]:
394
+ """Resolve and return a flattened, verification-aware summary.
395
+
396
+ Accepts the same ``lookup_txt`` / ``fetch_card`` overrides as :func:`resolve`.
397
+
398
+ Returns a dict with: ``valid``, ``domain``, ``version``, ``manifest_url``,
399
+ ``mcp_endpoint``, ``has_mcp``, ``has_card``, ``signature_verified``,
400
+ ``signature_reason``, ``key``, ``kid``, ``auth``, ``payments``, ``pay_to``,
401
+ ``tags``, ``warnings``, ``errors``. ``pay_to`` is populated only when the
402
+ signature verifies.
403
+ """
404
+ r = resolve(domain, timeout=timeout, **kwargs)
405
+ card = r["card"]
406
+ sig = r["signature"]
407
+ v = r["validation"]
408
+ signature_verified = bool(sig.get("verified"))
409
+
410
+ auth: List[str] = []
411
+ for entry in card.get("auth") or []:
412
+ if isinstance(entry, dict) and entry.get("scheme"):
413
+ auth.append(str(entry["scheme"]))
414
+ elif isinstance(entry, str):
415
+ auth.append(entry)
416
+
417
+ payment = card.get("payment") or {}
418
+ payments = [str(payment["type"])] if payment.get("type") else []
419
+ tags = [str(t) for t in card.get("tags")] if isinstance(card.get("tags"), list) else []
420
+
421
+ pay_to: List[str] = []
422
+ if signature_verified and isinstance(payment.get("payTo"), list):
423
+ pay_to = [str(a) for a in payment["payTo"]]
424
+
425
+ return {
426
+ "valid": bool(r["ok"] and v["valid"] and signature_verified),
427
+ "domain": r["domain"],
428
+ "version": r["dns"]["parsed"].get("v"),
429
+ "manifest_url": r["manifest_url"],
430
+ "mcp_endpoint": card.get("mcp"),
431
+ "has_mcp": bool(card.get("mcp")),
432
+ "has_card": bool(card),
433
+ "signature_verified": signature_verified,
434
+ "signature_reason": None if signature_verified else sig.get("reason"),
435
+ "key": r["dns"]["parsed"].get("k"),
436
+ "kid": sig.get("kid"),
437
+ "auth": auth,
438
+ "payments": payments,
439
+ "pay_to": pay_to,
440
+ "tags": tags,
441
+ "warnings": v["warnings"],
442
+ "errors": v["errors"],
443
+ }
File without changes
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: nais-sdk
3
+ Version: 1.0.0
4
+ Summary: Python SDK for the Network Agent Identity Standard (NAIS). Resolve, validate, and verify the signatures of NAIS-compliant agent cards.
5
+ License: MIT
6
+ Project-URL: Homepage, https://nais.id
7
+ Project-URL: Documentation, https://nais.id/sdks
8
+ Project-URL: Repository, https://github.com/nais-standard/clients
9
+ Project-URL: Bug Tracker, https://github.com/nais-standard/clients/issues
10
+ Keywords: nais,agent,identity,ai-agent,dns,resolver,mcp,web3,ed25519,signature
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: Name Service (DNS)
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: dnspython>=2.0
27
+ Requires-Dist: cryptography>=41
28
+ Provides-Extra: pynacl
29
+ Requires-Dist: PyNaCl>=1.5; extra == "pynacl"
30
+ Dynamic: license-file
31
+
32
+ # nais-sdk
33
+
34
+ Python SDK for the [Network Agent Identity Standard (NAIS)](https://nais.id).
35
+
36
+ Resolve and validate NAIS-compliant agent domains. Requires Python 3.8+ and depends on `dnspython` (DNS lookups) and `cryptography` (Ed25519 signature verification; PyNaCl is also supported). `pip install nais-sdk` pulls both. The SDK resolves directly: it reads the `_agent.<domain>` DNS TXT record, fetches the signed card over HTTPS (HTTPS-only, no cross-host redirects, 1 MiB cap), and verifies the card's mandatory Ed25519 signature against the DNS `k=` key. Server-side only — browsers cannot perform DNS lookups.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install nais-sdk
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### resolve(domain)
47
+
48
+ Resolves a domain directly via DNS and HTTPS and returns a structured result dictionary:
49
+
50
+ - `ok` — overall success flag.
51
+ - `domain` — the normalized domain.
52
+ - `agent_host` — the host serving the card.
53
+ - `dns` — `{records, parsed}`, where `parsed` exposes `v`, `manifest`, and `k`.
54
+ - `manifest_url` — the URL the card was fetched from.
55
+ - `card` — the decoded `agent.json`.
56
+ - `signature` — `{present, verified, kid, alg, reason}`.
57
+ - `validation` — `{valid, errors, warnings}`.
58
+
59
+ ```python
60
+ from nais_sdk import resolve
61
+
62
+ r = resolve("weatheragent.nais.id")
63
+ if r["signature"]["verified"]:
64
+ print(r["card"]["mcp"])
65
+ # https://weatheragent.nais.id/mcp
66
+
67
+ print(r["dns"]["parsed"]["k"])
68
+ # ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ
69
+ ```
70
+
71
+ `resolve()` raises on an invalid domain, when no NAIS TXT record is found, or when the card fetch fails. It does **not** raise on a bad signature — that surfaces as `signature["verified"] is False` and `validation["valid"] is False`.
72
+
73
+ ### validate(domain)
74
+
75
+ Returns a flattened summary dictionary. Best for quick validation checks before using an agent.
76
+
77
+ ```python
78
+ from nais_sdk import validate
79
+
80
+ summary = validate("weatheragent.nais.id")
81
+ print(summary)
82
+ ```
83
+
84
+ Example output:
85
+
86
+ ```python
87
+ {
88
+ "valid": True,
89
+ "domain": "weatheragent.nais.id",
90
+ "version": "nais1",
91
+ "manifest_url": "https://weatheragent.nais.id/.well-known/agent.json",
92
+ "mcp_endpoint": "https://weatheragent.nais.id/mcp",
93
+ "has_mcp": True,
94
+ "has_card": True,
95
+ "signature_verified": True,
96
+ "signature_reason": None,
97
+ "key": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
98
+ "kid": "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ",
99
+ "auth": ["wallet"],
100
+ "payments": ["x402"],
101
+ "pay_to": ["0x742d35Cc6634C0532925a3b8D4C9B7F1A2e3d4E5"],
102
+ "tags": ["forecast", "current_weather", "alerts"],
103
+ "warnings": [],
104
+ "errors": []
105
+ }
106
+ ```
107
+
108
+ `valid` is `True` only when the card resolved, the schema validates, **and** the signature verifies.
109
+
110
+ ```python
111
+ from nais_sdk import validate
112
+
113
+ summary = validate("weatheragent.nais.id")
114
+ if summary["signature_verified"]:
115
+ print(summary["tags"]) # ['forecast', 'current_weather', 'alerts']
116
+ print(summary["pay_to"]) # pay_to only populated for verified cards
117
+ ```
118
+
119
+ ### verify_card / canonicalize
120
+
121
+ The SDK also exports `verify_card(card, dns_key)` and `canonicalize(value)` for verifying a card you already hold against a DNS `k=` key. It also exports `parse_nais_txt` and `normalize_domain`.
122
+
123
+ ```python
124
+ from nais_sdk import verify_card, canonicalize
125
+
126
+ result = verify_card(card, "ed25519:oc5a92N1h1Vg9PlnM8CrB0MAw3mMddFhZTrVuMkzceQ")
127
+ print(result["verified"], result["reason"])
128
+ ```
129
+
130
+ ### Error handling
131
+
132
+ ```python
133
+ from nais_sdk import resolve, ResolutionError
134
+
135
+ try:
136
+ result = resolve("not-a-real-agent.example.com")
137
+ except ResolutionError as e:
138
+ print(f"Failed to resolve {e.domain}: {e}")
139
+ ```
140
+
141
+ ### Timeout
142
+
143
+ Both `resolve()` and `validate()` accept an optional `timeout` argument (default: 10 seconds):
144
+
145
+ ```python
146
+ result = resolve("weatheragent.nais.id", timeout=5)
147
+ ```
148
+
149
+ ### Testing without DNS or network
150
+
151
+ `resolve()` and `validate()` accept injected `lookup_txt` and `fetch_card` callables as keyword arguments, so resolution can be tested offline:
152
+
153
+ ```python
154
+ result = resolve(
155
+ "weatheragent.nais.id",
156
+ lookup_txt=lambda name: ["v=nais1; manifest=https://weatheragent.nais.id/.well-known/agent.json; k=ed25519:..."],
157
+ fetch_card=lambda url: {...}, # decoded agent.json
158
+ )
159
+ ```
160
+
161
+ ## Notes
162
+
163
+ - Requires Python 3.8+. Depends on `dnspython` (DNS) and `cryptography` (signatures; PyNaCl also supported); `pip install nais-sdk` installs both.
164
+ - Resolution is performed locally and directly: DNS TXT lookup, HTTPS card fetch, and Ed25519 signature verification all happen in-process. There is no central resolver.
165
+ - The card fetch is HTTPS-only, refuses cross-host redirects, and caps responses at 1 MiB.
166
+ - Server-side only: browsers cannot perform DNS lookups.
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nais_sdk/__init__.py
5
+ nais_sdk/py.typed
6
+ nais_sdk.egg-info/PKG-INFO
7
+ nais_sdk.egg-info/SOURCES.txt
8
+ nais_sdk.egg-info/dependency_links.txt
9
+ nais_sdk.egg-info/requires.txt
10
+ nais_sdk.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ dnspython>=2.0
2
+ cryptography>=41
3
+
4
+ [pynacl]
5
+ PyNaCl>=1.5
@@ -0,0 +1 @@
1
+ nais_sdk
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nais-sdk"
7
+ version = "1.0.0"
8
+ description = "Python SDK for the Network Agent Identity Standard (NAIS). Resolve, validate, and verify the signatures of NAIS-compliant agent cards."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ # DNS TXT resolution uses dnspython (no stdlib equivalent). Signature
13
+ # verification uses Ed25519 via cryptography (PyNaCl supported as an alternative).
14
+ dependencies = ["dnspython>=2.0", "cryptography>=41"]
15
+ keywords = ["nais", "agent", "identity", "ai-agent", "dns", "resolver", "mcp", "web3", "ed25519", "signature"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Internet :: Name Service (DNS)",
27
+ "Topic :: Security :: Cryptography",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ pynacl = ["PyNaCl>=1.5"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://nais.id"
36
+ Documentation = "https://nais.id/sdks"
37
+ Repository = "https://github.com/nais-standard/clients"
38
+ "Bug Tracker" = "https://github.com/nais-standard/clients/issues"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["."]
42
+ include = ["nais_sdk*"]
43
+
44
+ [tool.setuptools.package-data]
45
+ nais_sdk = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+