vaid-a2a 0.1.1__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.
- vaid_a2a-0.1.1/.gitignore +30 -0
- vaid_a2a-0.1.1/PKG-INFO +216 -0
- vaid_a2a-0.1.1/README.md +192 -0
- vaid_a2a-0.1.1/examples/guard.py +54 -0
- vaid_a2a-0.1.1/pyproject.toml +68 -0
- vaid_a2a-0.1.1/tests/test_vectors.py +94 -0
- vaid_a2a-0.1.1/tests/test_verifier.py +688 -0
- vaid_a2a-0.1.1/vaid_a2a/__init__.py +33 -0
- vaid_a2a-0.1.1/vaid_a2a/conformance.py +188 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/01-valid-two-hop-chain.json +261 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/02-child-scope-wider-than-parent.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/03-expired-leaf.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/04-revoked-middle-link.json +384 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/05-action-outside-leaf-scope.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/06-unknown-root-key.json +261 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/07-sibling-scope-child.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/08-capability-not-in-parent.json +261 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/09-tenant-changed.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/10-child-outlives-parent.json +260 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/11-unrestricted-child.json +258 -0
- vaid_a2a-0.1.1/vaid_a2a/vectors/12-trust-domain-not-bound-to-signing-key.json +144 -0
- vaid_a2a-0.1.1/vaid_a2a/verifier.py +520 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
/target
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
Cargo.lock.orig
|
|
5
|
+
|
|
6
|
+
# Backtraces
|
|
7
|
+
rustc-ice-*.txt
|
|
8
|
+
|
|
9
|
+
# Editor / OS
|
|
10
|
+
.DS_Store
|
|
11
|
+
*.swp
|
|
12
|
+
.idea/
|
|
13
|
+
.vscode/
|
|
14
|
+
|
|
15
|
+
# Python
|
|
16
|
+
__pycache__/
|
|
17
|
+
*.py[cod]
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
dist/
|
|
21
|
+
build/
|
|
22
|
+
.venv/
|
|
23
|
+
.buildvenv/
|
|
24
|
+
.installtest/
|
|
25
|
+
|
|
26
|
+
# TypeScript / npm
|
|
27
|
+
node_modules/
|
|
28
|
+
typescript/*/dist/
|
|
29
|
+
typescript/*/test-dist/
|
|
30
|
+
*.tsbuildinfo
|
vaid_a2a-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vaid-a2a
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reference verifier for the VAID A2A extension (docs/a2a/v1/extension.md): checks a VAID carried in an A2A Message against signature, expiry, chain attenuation, requested-action scope and revocation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/solara-associates/vaid
|
|
6
|
+
Project-URL: Repository, https://github.com/solara-associates/vaid
|
|
7
|
+
Project-URL: Source, https://github.com/solara-associates/vaid/tree/main/python/vaid-a2a
|
|
8
|
+
Project-URL: Issues, https://github.com/solara-associates/vaid/issues
|
|
9
|
+
Author: solara.associates
|
|
10
|
+
Author-email: "solara.associates" <info@solara.associates>
|
|
11
|
+
License: Apache-2.0
|
|
12
|
+
Keywords: a2a,agent-identity,agent2agent,attenuation,delegation,vaid
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: vaid-mint<0.10,>=0.9.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# vaid-a2a
|
|
26
|
+
|
|
27
|
+
Reference verifier for the VAID [A2A](https://a2a-protocol.org) extension
|
|
28
|
+
specified in [`docs/a2a/v1/extension.md`](../../docs/a2a/v1/extension.md)
|
|
29
|
+
(**draft**).
|
|
30
|
+
|
|
31
|
+
A2A lets agents delegate work to agents. [a2aproject/A2A#2028](https://github.com/a2aproject/A2A/issues/2028)
|
|
32
|
+
proposes `actorChain`, a caller-supplied record of who acted for whom, and is
|
|
33
|
+
explicit that a self-reported chain can narrow perfectly while proving no
|
|
34
|
+
actual authority. This extension supplies one concrete, offline-checkable
|
|
35
|
+
proof a hop can point at to fill that gap: a VAID — a signed, attenuated,
|
|
36
|
+
revocable delegation document this repo already mints and verifies in three
|
|
37
|
+
languages.
|
|
38
|
+
|
|
39
|
+
This package does not implement the extension's wire format; it implements
|
|
40
|
+
the **verification side**: given an A2A `Message` (as a plain `dict`) and a
|
|
41
|
+
trust configuration, it extracts any VAID(s) carried under the extension's
|
|
42
|
+
metadata keys and runs the ordered check the spec defines, returning a typed
|
|
43
|
+
allow/deny result with a reason — never a bare boolean, so a caller can log,
|
|
44
|
+
alert, or retry the right thing.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install -e python/vaid-pop -e python/vaid-mint -e python/vaid-a2a
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
(No published release yet — this is a draft extension on a feature branch.
|
|
53
|
+
See `docs/a2a/drafts/a2a-2028-reply.md` and the branch's `REPORT-306.md`.)
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from datetime import datetime, timezone
|
|
59
|
+
|
|
60
|
+
from vaid_mint.revocation import InMemoryRevocationList
|
|
61
|
+
from vaid_a2a import verify_a2a_message, METADATA_VAID_KEY
|
|
62
|
+
|
|
63
|
+
trust_config = {
|
|
64
|
+
"trustedIssuers": [
|
|
65
|
+
{"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
message = {
|
|
70
|
+
"metadata": {
|
|
71
|
+
METADATA_VAID_KEY: leaf_vaid_document, # a dict, as produced by vaid_mint
|
|
72
|
+
# METADATA_CHAIN_KEY: [ancestor_vaid_1, ...], # optional, for chain checks
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
result = verify_a2a_message(
|
|
77
|
+
message,
|
|
78
|
+
trust_config=trust_config,
|
|
79
|
+
# A plain, never-populated InMemoryRevocationList() is UNAVAILABLE by
|
|
80
|
+
# design (R.4.5/R.4.6), so this example as written DENIES EVERY REQUEST
|
|
81
|
+
# with VerifyCode.INDETERMINATE, however valid the VAID is. That is
|
|
82
|
+
# deliberate: a copy-pasted revocation seam with nothing wired to it
|
|
83
|
+
# must fail closed, never read as "nothing is revoked". See "Configuring
|
|
84
|
+
# real revocation" below for what plugging in an actual store takes.
|
|
85
|
+
revocation=InMemoryRevocationList(),
|
|
86
|
+
requested_action="data.acme.orders",
|
|
87
|
+
now=datetime.now(timezone.utc),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if result.allowed:
|
|
91
|
+
... # proceed
|
|
92
|
+
else:
|
|
93
|
+
... # result.code (a VerifyCode) and result.reason explain why
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Valid requests are refused as `indeterminate` until a real
|
|
97
|
+
`RevocationCheck` is configured.** This is not a bug to work around; it is
|
|
98
|
+
the fail-closed default R.4.5 requires. The copy-paste path above stays
|
|
99
|
+
closed on purpose, so a deployment cannot go live by accident with
|
|
100
|
+
revocation silently assumed clean.
|
|
101
|
+
|
|
102
|
+
See `examples/guard.py` for use as a guard in front of a request handler.
|
|
103
|
+
|
|
104
|
+
### Configuring real revocation
|
|
105
|
+
|
|
106
|
+
Implement `vaid_mint.revocation.RevocationCheck` against whatever actually
|
|
107
|
+
tracks revoked VAIDs in your deployment (a database table, a feed, a cache
|
|
108
|
+
in front of either) and pass an instance of it instead of
|
|
109
|
+
`InMemoryRevocationList()`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from vaid_mint.revocation import RevocationCheck, RevocationStatus
|
|
113
|
+
|
|
114
|
+
class DatabaseRevocationCheck:
|
|
115
|
+
"""Minimal real RevocationCheck: ask the database once per lineage."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, db):
|
|
118
|
+
self._db = db
|
|
119
|
+
|
|
120
|
+
def check_lineage(self, lineage: list[str]) -> RevocationStatus:
|
|
121
|
+
try:
|
|
122
|
+
revoked_ids = self._db.revoked_vaid_ids(lineage)
|
|
123
|
+
except Exception:
|
|
124
|
+
return RevocationStatus.UNAVAILABLE # fail closed, not NOT_REVOKED
|
|
125
|
+
if revoked_ids:
|
|
126
|
+
return RevocationStatus.REVOKED
|
|
127
|
+
return RevocationStatus.NOT_REVOKED
|
|
128
|
+
|
|
129
|
+
result = verify_a2a_message(
|
|
130
|
+
message,
|
|
131
|
+
trust_config=trust_config,
|
|
132
|
+
revocation=DatabaseRevocationCheck(db),
|
|
133
|
+
requested_action="data.acme.orders",
|
|
134
|
+
now=datetime.now(timezone.utc),
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`check_lineage` receives the full ordered lineage (root first, leaf last)
|
|
139
|
+
that `verify_a2a_message` already assembled; it is never asked to resolve
|
|
140
|
+
ancestry itself (R.4.1). `RevocationStatus` is three-valued on purpose: a
|
|
141
|
+
database error or an unreachable feed must report `UNAVAILABLE`, never
|
|
142
|
+
`NOT_REVOKED`.
|
|
143
|
+
|
|
144
|
+
### Local demo only, never production
|
|
145
|
+
|
|
146
|
+
`InMemoryRevocationList.assume_nothing_revoked()` exists for local demos and
|
|
147
|
+
tests that have no revocation source to wire up and want to exercise the
|
|
148
|
+
rest of the check (signature, expiry, chain, scope) without every call
|
|
149
|
+
denying on revocation. It is **never** appropriate outside a local demo: it
|
|
150
|
+
makes every lineage read as not-revoked, unconditionally, regardless of
|
|
151
|
+
whether anything actually was.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# LOCAL DEMO ONLY, NEVER PRODUCTION.
|
|
155
|
+
from vaid_mint.revocation import InMemoryRevocationList
|
|
156
|
+
|
|
157
|
+
demo_result = verify_a2a_message(
|
|
158
|
+
message,
|
|
159
|
+
trust_config=trust_config,
|
|
160
|
+
revocation=InMemoryRevocationList.assume_nothing_revoked(),
|
|
161
|
+
requested_action="data.acme.orders",
|
|
162
|
+
now=datetime.now(timezone.utc),
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## What this checks, and in what order
|
|
167
|
+
|
|
168
|
+
Exactly the order `docs/a2a/v1/extension.md` §5 specifies — signature, leaf
|
|
169
|
+
expiry, chain integrity, per-hop attenuation, requested-action scope,
|
|
170
|
+
revocation — fail closed at the first failing step. See the module docstring
|
|
171
|
+
in `vaid_a2a/verifier.py` for the full reasoning behind the order; it mirrors
|
|
172
|
+
`vaid_mint.verify` and `vaid_mint.chain`, which this package delegates to
|
|
173
|
+
rather than reimplements.
|
|
174
|
+
|
|
175
|
+
| step | delegates to |
|
|
176
|
+
|---|---|
|
|
177
|
+
| 1. signature / authenticity | `vaid_mint.verify.verify_vaid_authenticity_graded` |
|
|
178
|
+
| 2. leaf expiry | `vaid_mint.document.is_expired_at` |
|
|
179
|
+
| 3. chain integrity | `vaid_mint.chain.verify_chain_at` |
|
|
180
|
+
| 4. per-hop attenuation | `vaid_mint.chain.verify_chain_at` (same call) |
|
|
181
|
+
| 5. requested action in leaf scope | `vaid_mint.document.is_in_scope` |
|
|
182
|
+
| 6. revocation over the full lineage | the caller's own `vaid_mint.revocation.RevocationCheck` |
|
|
183
|
+
|
|
184
|
+
## Test vectors
|
|
185
|
+
|
|
186
|
+
`tests/test_vectors.py` runs every vector in `docs/a2a/v1/vectors/*.json` and
|
|
187
|
+
asserts the expected result. Regenerate the vectors from `vaid-mint` tooling
|
|
188
|
+
with `docs/a2a/v1/vectors/generate_vectors.py`; see that directory's own README.
|
|
189
|
+
|
|
190
|
+
## Packaged conformance firewall
|
|
191
|
+
|
|
192
|
+
The vectors above also ship **inside the wheel**, so an installed copy can be
|
|
193
|
+
checked without this repository:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
vaid-a2a-conformance # exit 0 = PASS, 1 = BLOCKER
|
|
197
|
+
python -m vaid_a2a.conformance # same
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
It replays all 12 extension vectors through `verify_a2a_message` and asserts the
|
|
201
|
+
`(result, error_code)` pair `docs/a2a/v1/extension.md` section 5 specifies for
|
|
202
|
+
each, at the verification instant each vector names rather than the wall clock.
|
|
203
|
+
It pins **verdicts**, not bytes: this package signs and canonicalizes nothing of
|
|
204
|
+
its own, and the bytes beneath it are proven by `vaid-mint-conformance`. The
|
|
205
|
+
bundled vectors are byte-identical to `docs/a2a/v1/vectors/`, which CI checks
|
|
206
|
+
by comparing the file listings and then every file.
|
|
207
|
+
|
|
208
|
+
## Non-goals
|
|
209
|
+
|
|
210
|
+
- Does not depend on `a2a-python` or any A2A SDK — `Message` is accepted as a
|
|
211
|
+
plain `dict`, so this works against any A2A server or client.
|
|
212
|
+
- Does not mint VAIDs (`vaid_mint.mint.MintService`) or implement revocation
|
|
213
|
+
transport (`docs/spec/revocation.md` R.1) — both out of scope here, as they
|
|
214
|
+
are for the extension itself.
|
|
215
|
+
- Does not implement `actorChain` (#2028) or require it to be present; see
|
|
216
|
+
the spec's §4.1 for how the two compose when both are active.
|
vaid_a2a-0.1.1/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# vaid-a2a
|
|
2
|
+
|
|
3
|
+
Reference verifier for the VAID [A2A](https://a2a-protocol.org) extension
|
|
4
|
+
specified in [`docs/a2a/v1/extension.md`](../../docs/a2a/v1/extension.md)
|
|
5
|
+
(**draft**).
|
|
6
|
+
|
|
7
|
+
A2A lets agents delegate work to agents. [a2aproject/A2A#2028](https://github.com/a2aproject/A2A/issues/2028)
|
|
8
|
+
proposes `actorChain`, a caller-supplied record of who acted for whom, and is
|
|
9
|
+
explicit that a self-reported chain can narrow perfectly while proving no
|
|
10
|
+
actual authority. This extension supplies one concrete, offline-checkable
|
|
11
|
+
proof a hop can point at to fill that gap: a VAID — a signed, attenuated,
|
|
12
|
+
revocable delegation document this repo already mints and verifies in three
|
|
13
|
+
languages.
|
|
14
|
+
|
|
15
|
+
This package does not implement the extension's wire format; it implements
|
|
16
|
+
the **verification side**: given an A2A `Message` (as a plain `dict`) and a
|
|
17
|
+
trust configuration, it extracts any VAID(s) carried under the extension's
|
|
18
|
+
metadata keys and runs the ordered check the spec defines, returning a typed
|
|
19
|
+
allow/deny result with a reason — never a bare boolean, so a caller can log,
|
|
20
|
+
alert, or retry the right thing.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install -e python/vaid-pop -e python/vaid-mint -e python/vaid-a2a
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
(No published release yet — this is a draft extension on a feature branch.
|
|
29
|
+
See `docs/a2a/drafts/a2a-2028-reply.md` and the branch's `REPORT-306.md`.)
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
|
|
36
|
+
from vaid_mint.revocation import InMemoryRevocationList
|
|
37
|
+
from vaid_a2a import verify_a2a_message, METADATA_VAID_KEY
|
|
38
|
+
|
|
39
|
+
trust_config = {
|
|
40
|
+
"trustedIssuers": [
|
|
41
|
+
{"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
message = {
|
|
46
|
+
"metadata": {
|
|
47
|
+
METADATA_VAID_KEY: leaf_vaid_document, # a dict, as produced by vaid_mint
|
|
48
|
+
# METADATA_CHAIN_KEY: [ancestor_vaid_1, ...], # optional, for chain checks
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
result = verify_a2a_message(
|
|
53
|
+
message,
|
|
54
|
+
trust_config=trust_config,
|
|
55
|
+
# A plain, never-populated InMemoryRevocationList() is UNAVAILABLE by
|
|
56
|
+
# design (R.4.5/R.4.6), so this example as written DENIES EVERY REQUEST
|
|
57
|
+
# with VerifyCode.INDETERMINATE, however valid the VAID is. That is
|
|
58
|
+
# deliberate: a copy-pasted revocation seam with nothing wired to it
|
|
59
|
+
# must fail closed, never read as "nothing is revoked". See "Configuring
|
|
60
|
+
# real revocation" below for what plugging in an actual store takes.
|
|
61
|
+
revocation=InMemoryRevocationList(),
|
|
62
|
+
requested_action="data.acme.orders",
|
|
63
|
+
now=datetime.now(timezone.utc),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if result.allowed:
|
|
67
|
+
... # proceed
|
|
68
|
+
else:
|
|
69
|
+
... # result.code (a VerifyCode) and result.reason explain why
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Valid requests are refused as `indeterminate` until a real
|
|
73
|
+
`RevocationCheck` is configured.** This is not a bug to work around; it is
|
|
74
|
+
the fail-closed default R.4.5 requires. The copy-paste path above stays
|
|
75
|
+
closed on purpose, so a deployment cannot go live by accident with
|
|
76
|
+
revocation silently assumed clean.
|
|
77
|
+
|
|
78
|
+
See `examples/guard.py` for use as a guard in front of a request handler.
|
|
79
|
+
|
|
80
|
+
### Configuring real revocation
|
|
81
|
+
|
|
82
|
+
Implement `vaid_mint.revocation.RevocationCheck` against whatever actually
|
|
83
|
+
tracks revoked VAIDs in your deployment (a database table, a feed, a cache
|
|
84
|
+
in front of either) and pass an instance of it instead of
|
|
85
|
+
`InMemoryRevocationList()`:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from vaid_mint.revocation import RevocationCheck, RevocationStatus
|
|
89
|
+
|
|
90
|
+
class DatabaseRevocationCheck:
|
|
91
|
+
"""Minimal real RevocationCheck: ask the database once per lineage."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, db):
|
|
94
|
+
self._db = db
|
|
95
|
+
|
|
96
|
+
def check_lineage(self, lineage: list[str]) -> RevocationStatus:
|
|
97
|
+
try:
|
|
98
|
+
revoked_ids = self._db.revoked_vaid_ids(lineage)
|
|
99
|
+
except Exception:
|
|
100
|
+
return RevocationStatus.UNAVAILABLE # fail closed, not NOT_REVOKED
|
|
101
|
+
if revoked_ids:
|
|
102
|
+
return RevocationStatus.REVOKED
|
|
103
|
+
return RevocationStatus.NOT_REVOKED
|
|
104
|
+
|
|
105
|
+
result = verify_a2a_message(
|
|
106
|
+
message,
|
|
107
|
+
trust_config=trust_config,
|
|
108
|
+
revocation=DatabaseRevocationCheck(db),
|
|
109
|
+
requested_action="data.acme.orders",
|
|
110
|
+
now=datetime.now(timezone.utc),
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`check_lineage` receives the full ordered lineage (root first, leaf last)
|
|
115
|
+
that `verify_a2a_message` already assembled; it is never asked to resolve
|
|
116
|
+
ancestry itself (R.4.1). `RevocationStatus` is three-valued on purpose: a
|
|
117
|
+
database error or an unreachable feed must report `UNAVAILABLE`, never
|
|
118
|
+
`NOT_REVOKED`.
|
|
119
|
+
|
|
120
|
+
### Local demo only, never production
|
|
121
|
+
|
|
122
|
+
`InMemoryRevocationList.assume_nothing_revoked()` exists for local demos and
|
|
123
|
+
tests that have no revocation source to wire up and want to exercise the
|
|
124
|
+
rest of the check (signature, expiry, chain, scope) without every call
|
|
125
|
+
denying on revocation. It is **never** appropriate outside a local demo: it
|
|
126
|
+
makes every lineage read as not-revoked, unconditionally, regardless of
|
|
127
|
+
whether anything actually was.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
# LOCAL DEMO ONLY, NEVER PRODUCTION.
|
|
131
|
+
from vaid_mint.revocation import InMemoryRevocationList
|
|
132
|
+
|
|
133
|
+
demo_result = verify_a2a_message(
|
|
134
|
+
message,
|
|
135
|
+
trust_config=trust_config,
|
|
136
|
+
revocation=InMemoryRevocationList.assume_nothing_revoked(),
|
|
137
|
+
requested_action="data.acme.orders",
|
|
138
|
+
now=datetime.now(timezone.utc),
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## What this checks, and in what order
|
|
143
|
+
|
|
144
|
+
Exactly the order `docs/a2a/v1/extension.md` §5 specifies — signature, leaf
|
|
145
|
+
expiry, chain integrity, per-hop attenuation, requested-action scope,
|
|
146
|
+
revocation — fail closed at the first failing step. See the module docstring
|
|
147
|
+
in `vaid_a2a/verifier.py` for the full reasoning behind the order; it mirrors
|
|
148
|
+
`vaid_mint.verify` and `vaid_mint.chain`, which this package delegates to
|
|
149
|
+
rather than reimplements.
|
|
150
|
+
|
|
151
|
+
| step | delegates to |
|
|
152
|
+
|---|---|
|
|
153
|
+
| 1. signature / authenticity | `vaid_mint.verify.verify_vaid_authenticity_graded` |
|
|
154
|
+
| 2. leaf expiry | `vaid_mint.document.is_expired_at` |
|
|
155
|
+
| 3. chain integrity | `vaid_mint.chain.verify_chain_at` |
|
|
156
|
+
| 4. per-hop attenuation | `vaid_mint.chain.verify_chain_at` (same call) |
|
|
157
|
+
| 5. requested action in leaf scope | `vaid_mint.document.is_in_scope` |
|
|
158
|
+
| 6. revocation over the full lineage | the caller's own `vaid_mint.revocation.RevocationCheck` |
|
|
159
|
+
|
|
160
|
+
## Test vectors
|
|
161
|
+
|
|
162
|
+
`tests/test_vectors.py` runs every vector in `docs/a2a/v1/vectors/*.json` and
|
|
163
|
+
asserts the expected result. Regenerate the vectors from `vaid-mint` tooling
|
|
164
|
+
with `docs/a2a/v1/vectors/generate_vectors.py`; see that directory's own README.
|
|
165
|
+
|
|
166
|
+
## Packaged conformance firewall
|
|
167
|
+
|
|
168
|
+
The vectors above also ship **inside the wheel**, so an installed copy can be
|
|
169
|
+
checked without this repository:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
vaid-a2a-conformance # exit 0 = PASS, 1 = BLOCKER
|
|
173
|
+
python -m vaid_a2a.conformance # same
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
It replays all 12 extension vectors through `verify_a2a_message` and asserts the
|
|
177
|
+
`(result, error_code)` pair `docs/a2a/v1/extension.md` section 5 specifies for
|
|
178
|
+
each, at the verification instant each vector names rather than the wall clock.
|
|
179
|
+
It pins **verdicts**, not bytes: this package signs and canonicalizes nothing of
|
|
180
|
+
its own, and the bytes beneath it are proven by `vaid-mint-conformance`. The
|
|
181
|
+
bundled vectors are byte-identical to `docs/a2a/v1/vectors/`, which CI checks
|
|
182
|
+
by comparing the file listings and then every file.
|
|
183
|
+
|
|
184
|
+
## Non-goals
|
|
185
|
+
|
|
186
|
+
- Does not depend on `a2a-python` or any A2A SDK — `Message` is accepted as a
|
|
187
|
+
plain `dict`, so this works against any A2A server or client.
|
|
188
|
+
- Does not mint VAIDs (`vaid_mint.mint.MintService`) or implement revocation
|
|
189
|
+
transport (`docs/spec/revocation.md` R.1) — both out of scope here, as they
|
|
190
|
+
are for the extension itself.
|
|
191
|
+
- Does not implement `actorChain` (#2028) or require it to be present; see
|
|
192
|
+
the spec's §4.1 for how the two compose when both are active.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A minimal example: use vaid_a2a.verify_a2a_message as a guard in front of
|
|
2
|
+
an A2A request handler. Not wired to any specific A2A server framework —
|
|
3
|
+
`handle_message` below is the shape any framework's message handler takes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from vaid_a2a import METADATA_VAID_KEY, VerifyCode, verify_a2a_message
|
|
9
|
+
from vaid_mint.revocation import RevocationCheck
|
|
10
|
+
|
|
11
|
+
# Loaded once at startup from the AgentCard's own extension declaration.
|
|
12
|
+
TRUST_CONFIG = {
|
|
13
|
+
"trustedIssuers": [
|
|
14
|
+
{"trustDomain": "vaid.example", "kernelPublicKey": "<base64url, raw 32 bytes>"},
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def handle_message(message: dict, revocation: RevocationCheck) -> dict:
|
|
20
|
+
"""A stand-in for a real A2A message handler. ``message`` is the incoming
|
|
21
|
+
A2A ``Message`` as a dict; ``action`` is whatever this handler is about
|
|
22
|
+
to do, used as the ``requested_action`` scope check."""
|
|
23
|
+
action = "data.acme.orders"
|
|
24
|
+
|
|
25
|
+
result = verify_a2a_message(
|
|
26
|
+
message,
|
|
27
|
+
trust_config=TRUST_CONFIG,
|
|
28
|
+
revocation=revocation,
|
|
29
|
+
requested_action=action,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if not result.allowed:
|
|
33
|
+
# result.code is a VerifyCode — map it to whatever error shape this
|
|
34
|
+
# handler's A2A transport expects (see docs/a2a/v1/extension.md §6).
|
|
35
|
+
return {
|
|
36
|
+
"error": {
|
|
37
|
+
"code": result.code.value,
|
|
38
|
+
"message": result.reason,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if result.code is VerifyCode.NO_VAID:
|
|
43
|
+
# The extension was not activated, or carried no VAID, and
|
|
44
|
+
# requireVaid was false — this handler's own policy decides whether
|
|
45
|
+
# that is acceptable for this action. Shown here for completeness;
|
|
46
|
+
# a handler that requires a VAID for every action should instead
|
|
47
|
+
# pass require_vaid=True above and never reach this branch.
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return {"result": f"handled action {action!r}"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
print(METADATA_VAID_KEY)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaid-a2a"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Reference verifier for the VAID A2A extension (docs/a2a/v1/extension.md): checks a VAID carried in an A2A Message against signature, expiry, chain attenuation, requested-action scope and revocation."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
# Two entries by design: PEP 621 routes any entry that has an email
|
|
14
|
+
# entirely into Author-email, leaving the core Author field empty (PyPI
|
|
15
|
+
# then renders "Author: None"). The name-only entry populates Author;
|
|
16
|
+
# the second preserves the existing Author-email verbatim.
|
|
17
|
+
{ name = "solara.associates" },
|
|
18
|
+
{ name = "solara.associates", email = "info@solara.associates" },
|
|
19
|
+
]
|
|
20
|
+
keywords = ["vaid", "a2a", "agent-identity", "delegation", "attenuation", "agent2agent"]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"License :: OSI Approved :: Apache Software License",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
]
|
|
29
|
+
# vaid-mint supplies verification, chain-attenuation and revocation primitives
|
|
30
|
+
# (vaid_mint.verify, vaid_mint.chain, vaid_mint.document, vaid_mint.revocation) —
|
|
31
|
+
# this package is a thin extraction-and-ordering layer over them, not a second
|
|
32
|
+
# implementation. No dependency on a2a-python or any A2A SDK: Message is accepted
|
|
33
|
+
# as a plain dict so this works against any A2A server or client, per the brief.
|
|
34
|
+
dependencies = [
|
|
35
|
+
"vaid-mint>=0.9.0,<0.10",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
# The packaged firewall: an external consumer who installed ONLY the wheel can run
|
|
40
|
+
# `vaid-a2a-conformance` to prove the verifier they got reaches the verdict
|
|
41
|
+
# docs/a2a/v1/extension.md specifies for every published extension vector. It pins
|
|
42
|
+
# VERDICTS, not bytes - see the module docstring on why that is the normative
|
|
43
|
+
# surface for a verifier, and why it is a different claim rather than a weaker one.
|
|
44
|
+
vaid-a2a-conformance = "vaid_a2a.conformance:main"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
test = [
|
|
48
|
+
"pytest>=8.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/solara-associates/vaid"
|
|
53
|
+
Repository = "https://github.com/solara-associates/vaid"
|
|
54
|
+
Source = "https://github.com/solara-associates/vaid/tree/main/python/vaid-a2a"
|
|
55
|
+
Issues = "https://github.com/solara-associates/vaid/issues"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
# The extension vectors ship inside the wheel (vaid_a2a/vectors/, vendored
|
|
59
|
+
# byte-identically from docs/a2a/v1/vectors/ and cmp-checked in CI) so a
|
|
60
|
+
# consumer's firewall runs against the exact bytes this verifier was proven
|
|
61
|
+
# against - the same reason vaid_pop and vaid_mint bundle theirs.
|
|
62
|
+
packages = ["vaid_a2a"]
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = ["vaid_a2a", "tests", "examples", "README.md"]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Runs every vector in docs/a2a/v1/vectors/*.json against verify_a2a_message and
|
|
2
|
+
asserts the expected (result, error_code) pair.
|
|
3
|
+
|
|
4
|
+
The vectors live outside this package, under docs/a2a/v1/vectors/, because they
|
|
5
|
+
are the extension's vectors — not vaid-a2a's private fixtures — and
|
|
6
|
+
test_extension.py-equivalents in other languages would read the same files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from vaid_a2a import METADATA_CHAIN_KEY, METADATA_VAID_KEY, verify_a2a_message
|
|
18
|
+
from vaid_mint.revocation import InMemoryRevocationList
|
|
19
|
+
|
|
20
|
+
VECTORS_DIR = Path(__file__).resolve().parents[3] / "docs" / "a2a" / "v1" / "vectors"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_verification_time(raw: str) -> datetime:
|
|
24
|
+
"""Parse a vector's own ``verification_time`` (RFC 3339, ``Z`` suffix) —
|
|
25
|
+
never the wall clock, per the vectors README."""
|
|
26
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _vector_files() -> list[Path]:
|
|
30
|
+
files = sorted(VECTORS_DIR.glob("*.json"))
|
|
31
|
+
assert files, f"no vectors found under {VECTORS_DIR} — generator not run?"
|
|
32
|
+
return files
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(path: Path) -> dict:
|
|
36
|
+
return json.loads(path.read_text())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _revocation_for(revoked_ids: list[str]) -> InMemoryRevocationList:
|
|
40
|
+
store = InMemoryRevocationList()
|
|
41
|
+
if not revoked_ids:
|
|
42
|
+
# An empty revocation list must still VOUCH for "nothing revoked",
|
|
43
|
+
# not read as UNAVAILABLE (R.4.5/R.4.6) — assume_nothing_revoked()
|
|
44
|
+
# is the explicit, named way to get that posture rather than relying
|
|
45
|
+
# on an absent store by accident.
|
|
46
|
+
return InMemoryRevocationList.assume_nothing_revoked()
|
|
47
|
+
for vaid_id in revoked_ids:
|
|
48
|
+
store.revoke(vaid_id)
|
|
49
|
+
return store
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("vector_path", _vector_files(), ids=lambda p: p.stem)
|
|
53
|
+
def test_vector(vector_path: Path) -> None:
|
|
54
|
+
vector = _load(vector_path)
|
|
55
|
+
|
|
56
|
+
message = {
|
|
57
|
+
"metadata": {
|
|
58
|
+
METADATA_VAID_KEY: vector["inputs"]["leaf"],
|
|
59
|
+
METADATA_CHAIN_KEY: vector["inputs"]["chain"],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
revocation = _revocation_for(vector["revoked_vaid_ids"])
|
|
63
|
+
|
|
64
|
+
result = verify_a2a_message(
|
|
65
|
+
message,
|
|
66
|
+
trust_config=vector["trust_config"],
|
|
67
|
+
revocation=revocation,
|
|
68
|
+
requested_action=vector.get("requested_action"),
|
|
69
|
+
now=_parse_verification_time(vector["verification_time"]),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
expected_pass = vector["expected_result"] == "pass"
|
|
73
|
+
assert result.allowed is expected_pass, (
|
|
74
|
+
f"{vector_path.name}: expected allowed={expected_pass}, got "
|
|
75
|
+
f"{result.allowed} ({result.code.value}: {result.reason})"
|
|
76
|
+
)
|
|
77
|
+
if vector.get("expected_error_code") is not None:
|
|
78
|
+
assert result.code.value == vector["expected_error_code"], (
|
|
79
|
+
f"{vector_path.name}: expected code "
|
|
80
|
+
f"{vector['expected_error_code']!r}, got {result.code.value!r} "
|
|
81
|
+
f"({result.reason})"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_all_vectors_covered() -> None:
|
|
86
|
+
"""A guard against the parametrized test silently collecting zero cases —
|
|
87
|
+
the exact "ALL-PASS, a workflow not started" shape: a suite with no
|
|
88
|
+
vectors loaded would otherwise report a clean, empty pass.
|
|
89
|
+
|
|
90
|
+
The floor tracks the number of vectors that actually exist. Left at a
|
|
91
|
+
stale lower number it stops being a guard against anything but total
|
|
92
|
+
absence: with 12 vectors published, a floor of 6 would let half of them
|
|
93
|
+
disappear and still report a clean pass."""
|
|
94
|
+
assert len(_vector_files()) >= 12
|