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 +21 -0
- nais_sdk-1.0.0/PKG-INFO +170 -0
- nais_sdk-1.0.0/README.md +139 -0
- nais_sdk-1.0.0/nais_sdk/__init__.py +443 -0
- nais_sdk-1.0.0/nais_sdk/py.typed +0 -0
- nais_sdk-1.0.0/nais_sdk.egg-info/PKG-INFO +170 -0
- nais_sdk-1.0.0/nais_sdk.egg-info/SOURCES.txt +10 -0
- nais_sdk-1.0.0/nais_sdk.egg-info/dependency_links.txt +1 -0
- nais_sdk-1.0.0/nais_sdk.egg-info/requires.txt +5 -0
- nais_sdk-1.0.0/nais_sdk.egg-info/top_level.txt +1 -0
- nais_sdk-1.0.0/pyproject.toml +45 -0
- nais_sdk-1.0.0/setup.cfg +4 -0
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.
|
nais_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
nais_sdk-1.0.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
nais_sdk-1.0.0/setup.cfg
ADDED