agent-identity-protocol 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_identity_protocol-0.1.0.dist-info/METADATA +199 -0
- agent_identity_protocol-0.1.0.dist-info/RECORD +18 -0
- agent_identity_protocol-0.1.0.dist-info/WHEEL +4 -0
- aip_core/__init__.py +11 -0
- aip_core/crypto.py +68 -0
- aip_core/document.py +123 -0
- aip_core/error.py +22 -0
- aip_core/identity.py +85 -0
- aip_mcp/__init__.py +2 -0
- aip_mcp/error.py +20 -0
- aip_mcp/middleware.py +54 -0
- aip_token/__init__.py +21 -0
- aip_token/chained.py +206 -0
- aip_token/claims.py +30 -0
- aip_token/compact.py +73 -0
- aip_token/delegation.py +16 -0
- aip_token/error.py +61 -0
- aip_token/policy.py +35 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-identity-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verifiable cryptographic identity and delegation for AI agents across MCP and A2A
|
|
5
|
+
Project-URL: Homepage, https://github.com/sunilp/aip
|
|
6
|
+
Project-URL: Documentation, https://github.com/sunilp/aip/blob/main/docs/quickstart.md
|
|
7
|
+
Project-URL: Repository, https://github.com/sunilp/aip
|
|
8
|
+
Author-email: Sunil Prakash <sunil@sunilprakash.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security :: Cryptography
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: base58>=2.1
|
|
18
|
+
Requires-Dist: biscuit-python>=0.4
|
|
19
|
+
Requires-Dist: cryptography>=43.0
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Agent Identity Protocol (AIP)
|
|
29
|
+
|
|
30
|
+
Verifiable, delegable identity for AI agents across MCP and A2A.
|
|
31
|
+
|
|
32
|
+
AIP gives every agent a cryptographic identity that flows across protocol boundaries. A single token answers: who authorized this, through which agents, with what scope at each hop, and what was the outcome. No blockchain, no wallet UX -- just Ed25519 keys and append-only token chains.
|
|
33
|
+
|
|
34
|
+
## Why AIP
|
|
35
|
+
|
|
36
|
+
- MCP has no authentication layer. A2A has self-declared identities with no attestation.
|
|
37
|
+
- When Agent A delegates to Agent B, no identity verification happens.
|
|
38
|
+
- No existing protocol combines identity, delegation, and provenance in a single verifiable artifact.
|
|
39
|
+
|
|
40
|
+
AIP fills this gap.
|
|
41
|
+
|
|
42
|
+
## Quick Start (Python)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e python/
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from aip_core.crypto import KeyPair
|
|
50
|
+
from aip_token.claims import AipClaims
|
|
51
|
+
from aip_token.compact import CompactToken
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
# Generate identity
|
|
55
|
+
kp = KeyPair.generate()
|
|
56
|
+
|
|
57
|
+
# Create a compact token for MCP tool access
|
|
58
|
+
claims = AipClaims(
|
|
59
|
+
iss="aip:key:ed25519:" + kp.public_key_multibase(),
|
|
60
|
+
sub="aip:web:example.com/tools/search",
|
|
61
|
+
scope=["tool:search"],
|
|
62
|
+
budget_usd=1.0,
|
|
63
|
+
max_depth=0,
|
|
64
|
+
iat=int(time.time()),
|
|
65
|
+
exp=int(time.time()) + 3600,
|
|
66
|
+
)
|
|
67
|
+
token = CompactToken.create(claims, kp)
|
|
68
|
+
|
|
69
|
+
# Send to MCP server
|
|
70
|
+
headers = {"X-AIP-Token": token}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Multi-Agent Delegation
|
|
74
|
+
|
|
75
|
+
When agents delegate to other agents, each hop cryptographically narrows scope:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from aip_token.chained import ChainedToken
|
|
79
|
+
|
|
80
|
+
root_kp = KeyPair.generate()
|
|
81
|
+
|
|
82
|
+
# Orchestrator: broad authority
|
|
83
|
+
token = ChainedToken.create_authority(
|
|
84
|
+
issuer="aip:web:myorg.com/orchestrator",
|
|
85
|
+
scopes=["tool:search", "tool:email"],
|
|
86
|
+
budget_cents=500,
|
|
87
|
+
max_depth=3,
|
|
88
|
+
ttl_seconds=3600,
|
|
89
|
+
keypair=root_kp,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Delegate to specialist: only search, lower budget
|
|
93
|
+
delegated = token.delegate(
|
|
94
|
+
delegator="aip:web:myorg.com/orchestrator",
|
|
95
|
+
delegate="aip:web:myorg.com/specialist",
|
|
96
|
+
scopes=["tool:search"],
|
|
97
|
+
budget_cents=100,
|
|
98
|
+
context="research task for user query",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Specialist verifies before calling tool
|
|
102
|
+
delegated.authorize("tool:search", root_kp.public_key_bytes()) # passes
|
|
103
|
+
delegated.authorize("tool:email", root_kp.public_key_bytes()) # raises -- attenuated away
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Two Token Modes
|
|
107
|
+
|
|
108
|
+
**Compact** (JWT + EdDSA) -- single hop, drop-in for existing MCP servers. Standard JWT libraries can verify.
|
|
109
|
+
|
|
110
|
+
**Chained** (Biscuit) -- multi-hop delegation with append-only blocks. Each block can only narrow scope, never widen. Datalog policy evaluation at each hop.
|
|
111
|
+
|
|
112
|
+
Start with compact. Upgrade to chained when you need delegation. Same identity scheme, same protocol bindings.
|
|
113
|
+
|
|
114
|
+
## Features
|
|
115
|
+
|
|
116
|
+
- DNS-based (`aip:web:`) and self-certifying (`aip:key:`) identity schemes
|
|
117
|
+
- Ed25519 cryptography, no algorithm negotiation
|
|
118
|
+
- MCP, A2A, and HTTP protocol bindings
|
|
119
|
+
- MCP middleware for token verification
|
|
120
|
+
- Delegation chains with cryptographic scope attenuation
|
|
121
|
+
- Budget tracking in integer cents
|
|
122
|
+
- Policy profiles: Simple (templated), Standard (curated Datalog), Advanced (full Datalog)
|
|
123
|
+
- Identity document self-signatures (protects against domain compromise)
|
|
124
|
+
|
|
125
|
+
## Installation
|
|
126
|
+
|
|
127
|
+
### Python (primary SDK)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cd python
|
|
131
|
+
pip install -e ".[dev]"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Rust (reference implementation)
|
|
135
|
+
|
|
136
|
+
```toml
|
|
137
|
+
[dependencies]
|
|
138
|
+
aip-core = { path = "rust/aip-core" }
|
|
139
|
+
aip-token = { path = "rust/aip-token" }
|
|
140
|
+
aip-mcp = { path = "rust/aip-mcp" }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Python
|
|
147
|
+
cd python && pytest tests/ -v
|
|
148
|
+
|
|
149
|
+
# Rust
|
|
150
|
+
cd rust && cargo test
|
|
151
|
+
|
|
152
|
+
# Cross-language interop
|
|
153
|
+
python -m pytest tests/conformance/ -v
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Documentation
|
|
157
|
+
|
|
158
|
+
- [Quickstart](docs/quickstart.md) -- 5 minutes to your first AIP token
|
|
159
|
+
- [Delegation guide](docs/guide-delegation.md) -- chained tokens, scope attenuation, policy profiles
|
|
160
|
+
- [Competitive analysis](docs/competitive-analysis.md) -- AIP vs OAuth, DID, UCAN, Macaroons, Biscuit, SPIFFE
|
|
161
|
+
- [Specification](SPEC.md) -- full protocol spec
|
|
162
|
+
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
- [Single-agent MCP](examples/single-agent-mcp/) -- agent authenticates to MCP tool server
|
|
166
|
+
- [Multi-agent delegation](examples/multi-agent-delegation/) -- orchestrator delegates to specialist, calls tool server
|
|
167
|
+
|
|
168
|
+
## Paper
|
|
169
|
+
|
|
170
|
+
The protocol design, experiments, and adversarial evaluation are described in:
|
|
171
|
+
|
|
172
|
+
> Sunil Prakash. **AIP: Agent Identity Protocol for Verifiable Delegation Across MCP and A2A.** arXiv preprint arXiv:2603.24775, 2026.
|
|
173
|
+
> [https://arxiv.org/abs/2603.24775](https://arxiv.org/abs/2603.24775)
|
|
174
|
+
|
|
175
|
+
### Citing
|
|
176
|
+
|
|
177
|
+
```bibtex
|
|
178
|
+
@article{prakash2026aip,
|
|
179
|
+
title={AIP: Agent Identity Protocol for Verifiable Delegation Across MCP and A2A},
|
|
180
|
+
author={Prakash, Sunil},
|
|
181
|
+
journal={arXiv preprint arXiv:2603.24775},
|
|
182
|
+
year={2026}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Related Papers
|
|
187
|
+
|
|
188
|
+
AIP is part of a multi-agent trust stack. Each paper addresses a different layer:
|
|
189
|
+
|
|
190
|
+
| Layer | Paper | arXiv |
|
|
191
|
+
|-------|-------|-------|
|
|
192
|
+
| **Identity** | AIP: Verifiable Delegation Across MCP and A2A | [2603.24775](https://arxiv.org/abs/2603.24775) |
|
|
193
|
+
| **Provenance** | The Provenance Paradox in Multi-Agent LLM Routing | [2603.18043](https://arxiv.org/abs/2603.18043) |
|
|
194
|
+
| **Protocol** | LDP: An Identity-Aware Protocol for Multi-Agent LLM Systems | [2603.08852](https://arxiv.org/abs/2603.08852) |
|
|
195
|
+
| **Reasoning** | DCI: Structured Collective Reasoning with Typed Epistemic Acts | [2603.11781](https://arxiv.org/abs/2603.11781) |
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
Apache 2.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
aip_core/__init__.py,sha256=JTy34CK9oA_SGn69PqiprPWa2I8YffCTzGqYuQyBjf4,283
|
|
2
|
+
aip_core/crypto.py,sha256=ByK7QMLPXU2iFVR267rSs_m4DhOXx7DoGQuRK7gBxF0,2287
|
|
3
|
+
aip_core/document.py,sha256=XXY1Bxk2enfme5ziNHlc_IIyAu8ITVs4OYXWiW-wR3E,4230
|
|
4
|
+
aip_core/error.py,sha256=GGiYW_T-gYic19lsI4jVz9JpCO0mORIuskFU12f6KcQ,262
|
|
5
|
+
aip_core/identity.py,sha256=Ms-LoXxgRI19V-iVs_dTvlQl-hN4ihCgewStks98B2I,2888
|
|
6
|
+
aip_mcp/__init__.py,sha256=OhVdl2jTyCAzfVkR4PwzpZHHLItLHONO5cYrbufltcs,119
|
|
7
|
+
aip_mcp/error.py,sha256=oaWkLSsDKM36wVj519a7sExPdwlvhxffIxX8GF5XFd4,725
|
|
8
|
+
aip_mcp/middleware.py,sha256=WoqMo4u-6shUSeqO1TyfG_KZrireElC2k2FeFaiX84s,1890
|
|
9
|
+
aip_token/__init__.py,sha256=u3BfKbRbopAp3MyVnjMWA4atA02Lt_tCxZIzolesIdg,568
|
|
10
|
+
aip_token/chained.py,sha256=2-0L_eY1BQJj9WIYL2xJUmzlOunAeFCRSqmO9eU09Sg,7029
|
|
11
|
+
aip_token/claims.py,sha256=1inWQBgryNP0-L5RXPzYd-9esryOjpXrTw4XYHZaGqQ,634
|
|
12
|
+
aip_token/compact.py,sha256=F0_AJ49e7d5FbM_cdb0hDzIrl9Qgmp306B9gQ1ZFngs,2518
|
|
13
|
+
aip_token/delegation.py,sha256=S3u6Lkgc9FUvl4jhM-1HXdllelc7QjavD0LMopwIZZ4,344
|
|
14
|
+
aip_token/error.py,sha256=PfLT71IMhX_GowtonQUvATPqeSQOAr--fMYACPciwwU,1830
|
|
15
|
+
aip_token/policy.py,sha256=nZGbeJ3n-zPyZ1k1Py7GbhXKVFEIhtU4Bp4zuu9hwjk,1283
|
|
16
|
+
agent_identity_protocol-0.1.0.dist-info/METADATA,sha256=V58PZXtLXsCYptBMauRT-Sb0fZbkQWEZm0hXwcIRmPo,6411
|
|
17
|
+
agent_identity_protocol-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
18
|
+
agent_identity_protocol-0.1.0.dist-info/RECORD,,
|
aip_core/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from aip_core.crypto import KeyPair, verify
|
|
2
|
+
from aip_core.identity import AipId
|
|
3
|
+
from aip_core.document import IdentityDocument
|
|
4
|
+
from aip_core.error import (
|
|
5
|
+
AipError,
|
|
6
|
+
InvalidIdentifier,
|
|
7
|
+
InvalidDocument,
|
|
8
|
+
SignatureInvalid,
|
|
9
|
+
DocumentExpired,
|
|
10
|
+
VersionUnsupported,
|
|
11
|
+
)
|
aip_core/crypto.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Cryptographic primitives for AIP: Ed25519 key pairs, signing, and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base58
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
7
|
+
Ed25519PrivateKey,
|
|
8
|
+
Ed25519PublicKey,
|
|
9
|
+
)
|
|
10
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
11
|
+
Encoding,
|
|
12
|
+
NoEncryption,
|
|
13
|
+
PrivateFormat,
|
|
14
|
+
PublicFormat,
|
|
15
|
+
)
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KeyPair:
|
|
20
|
+
"""Ed25519 key pair for AIP identity operations."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, private_key: Ed25519PrivateKey) -> None:
|
|
23
|
+
self._private_key = private_key
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def generate(cls) -> KeyPair:
|
|
27
|
+
"""Generate a new Ed25519 key pair."""
|
|
28
|
+
return cls(Ed25519PrivateKey.generate())
|
|
29
|
+
|
|
30
|
+
def public_key_bytes(self) -> bytes:
|
|
31
|
+
"""Return the raw 32-byte public key."""
|
|
32
|
+
return self._private_key.public_key().public_bytes(
|
|
33
|
+
Encoding.Raw, PublicFormat.Raw
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def private_key_bytes(self) -> bytes:
|
|
37
|
+
"""Return the raw 32-byte Ed25519 private key."""
|
|
38
|
+
return self._private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
|
39
|
+
|
|
40
|
+
def public_key_multibase(self) -> str:
|
|
41
|
+
"""Return the public key as a z-prefix base58btc multibase string."""
|
|
42
|
+
return "z" + base58.b58encode(self.public_key_bytes()).decode("ascii")
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def decode_multibase(s: str) -> bytes:
|
|
46
|
+
"""Decode a z-prefix base58btc multibase string to raw bytes."""
|
|
47
|
+
if not s.startswith("z"):
|
|
48
|
+
raise ValueError("multibase string must start with 'z' (base58btc)")
|
|
49
|
+
return base58.b58decode(s[1:])
|
|
50
|
+
|
|
51
|
+
def sign(self, message: bytes) -> bytes:
|
|
52
|
+
"""Sign a message with Ed25519 and return the 64-byte signature."""
|
|
53
|
+
return self._private_key.sign(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sign(kp: KeyPair, message: bytes) -> bytes:
|
|
57
|
+
"""Sign a message using the given key pair."""
|
|
58
|
+
return kp.sign(message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def verify(public_key_bytes: bytes, message: bytes, signature: bytes) -> bool:
|
|
62
|
+
"""Verify an Ed25519 signature. Returns True if valid, False otherwise."""
|
|
63
|
+
pub = Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
64
|
+
try:
|
|
65
|
+
pub.verify(signature, message)
|
|
66
|
+
return True
|
|
67
|
+
except InvalidSignature:
|
|
68
|
+
return False
|
aip_core/document.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""AIP Identity Document model, parsing, signing, and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from aip_core.crypto import KeyPair, verify
|
|
13
|
+
from aip_core.error import (
|
|
14
|
+
DocumentExpired,
|
|
15
|
+
InvalidDocument,
|
|
16
|
+
SignatureInvalid,
|
|
17
|
+
VersionUnsupported,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PublicKeyEntry(BaseModel):
|
|
22
|
+
"""A single public key entry in an identity document."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
type: str
|
|
26
|
+
public_key_multibase: str
|
|
27
|
+
valid_from: Optional[str] = None
|
|
28
|
+
valid_until: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IdentityDocument(BaseModel):
|
|
32
|
+
"""AIP Identity Document."""
|
|
33
|
+
|
|
34
|
+
aip: str
|
|
35
|
+
id: str
|
|
36
|
+
public_keys: List[PublicKeyEntry] = Field(default_factory=list)
|
|
37
|
+
name: Optional[str] = None
|
|
38
|
+
delegation: Optional[Dict[str, Any]] = None
|
|
39
|
+
protocols: Optional[List[Dict[str, Any]]] = None
|
|
40
|
+
revocation: Optional[Dict[str, Any]] = None
|
|
41
|
+
extensions: Optional[Dict[str, Any]] = None
|
|
42
|
+
document_signature: Optional[str] = None
|
|
43
|
+
expires: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_json(cls, s: str) -> IdentityDocument:
|
|
47
|
+
"""Parse and validate a JSON identity document string."""
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(s)
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
raise InvalidDocument(f"invalid JSON: {exc}") from exc
|
|
52
|
+
return cls.model_validate(data)
|
|
53
|
+
|
|
54
|
+
def canonical_json(self) -> str:
|
|
55
|
+
"""Return the canonical JSON representation.
|
|
56
|
+
|
|
57
|
+
Canonical form: sorted keys, no whitespace, document_signature field excluded.
|
|
58
|
+
"""
|
|
59
|
+
data = self.model_dump(exclude_none=True)
|
|
60
|
+
data.pop("document_signature", None)
|
|
61
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
62
|
+
|
|
63
|
+
def verify_signature(self) -> None:
|
|
64
|
+
"""Verify the document signature against the first public key.
|
|
65
|
+
|
|
66
|
+
Raises SignatureInvalid if the signature is missing or does not match.
|
|
67
|
+
"""
|
|
68
|
+
if not self.document_signature:
|
|
69
|
+
raise SignatureInvalid("document_signature is missing")
|
|
70
|
+
if not self.public_keys:
|
|
71
|
+
raise SignatureInvalid("no public keys in document")
|
|
72
|
+
|
|
73
|
+
# Decode the base64 signature
|
|
74
|
+
try:
|
|
75
|
+
sig_bytes = base64.b64decode(self.document_signature)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise SignatureInvalid(f"invalid base64 signature: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
# Get the first public key
|
|
80
|
+
key_entry = self.public_keys[0]
|
|
81
|
+
pub_bytes = KeyPair.decode_multibase(key_entry.public_key_multibase)
|
|
82
|
+
|
|
83
|
+
canonical = self.canonical_json()
|
|
84
|
+
if not verify(pub_bytes, canonical.encode("utf-8"), sig_bytes):
|
|
85
|
+
raise SignatureInvalid("document signature verification failed")
|
|
86
|
+
|
|
87
|
+
def find_valid_key(self, at: datetime) -> Optional[PublicKeyEntry]:
|
|
88
|
+
"""Find the first public key valid at the given datetime.
|
|
89
|
+
|
|
90
|
+
A key is valid if:
|
|
91
|
+
- valid_from is None or at >= valid_from
|
|
92
|
+
- valid_until is None or at <= valid_until
|
|
93
|
+
"""
|
|
94
|
+
for key in self.public_keys:
|
|
95
|
+
if key.valid_from is not None:
|
|
96
|
+
vf = datetime.fromisoformat(key.valid_from)
|
|
97
|
+
if at < vf:
|
|
98
|
+
continue
|
|
99
|
+
if key.valid_until is not None:
|
|
100
|
+
vu = datetime.fromisoformat(key.valid_until)
|
|
101
|
+
if at > vu:
|
|
102
|
+
continue
|
|
103
|
+
return key
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def check_version(self) -> None:
|
|
107
|
+
"""Check that the document version is supported (major version <= 1).
|
|
108
|
+
|
|
109
|
+
Raises VersionUnsupported if the major version exceeds 1.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
major = int(self.aip.split(".")[0])
|
|
113
|
+
except (ValueError, IndexError) as exc:
|
|
114
|
+
raise VersionUnsupported(f"cannot parse version: {self.aip!r}") from exc
|
|
115
|
+
if major > 1:
|
|
116
|
+
raise VersionUnsupported(f"unsupported AIP version: {self.aip}")
|
|
117
|
+
|
|
118
|
+
def is_expired(self, at: datetime) -> bool:
|
|
119
|
+
"""Return True if the document has expired at the given datetime."""
|
|
120
|
+
if self.expires is None:
|
|
121
|
+
return False
|
|
122
|
+
exp = datetime.fromisoformat(self.expires)
|
|
123
|
+
return at >= exp
|
aip_core/error.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class AipError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvalidIdentifier(AipError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidDocument(AipError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SignatureInvalid(AipError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DocumentExpired(AipError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VersionUnsupported(AipError):
|
|
22
|
+
pass
|
aip_core/identity.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""AIP identity parsing and resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from aip_core.error import InvalidIdentifier
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AipId:
|
|
13
|
+
"""Parsed AIP identifier.
|
|
14
|
+
|
|
15
|
+
For web identifiers: scheme="web", domain=..., path=... (path may be None)
|
|
16
|
+
For key identifiers: scheme="key", algorithm=..., public_key_multibase=...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
scheme: str
|
|
20
|
+
domain: Optional[str] = None
|
|
21
|
+
path: Optional[str] = None
|
|
22
|
+
algorithm: Optional[str] = None
|
|
23
|
+
public_key_multibase: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def parse(cls, s: str) -> AipId:
|
|
27
|
+
"""Parse an AIP identifier string.
|
|
28
|
+
|
|
29
|
+
Accepted formats:
|
|
30
|
+
- aip:web:domain/path
|
|
31
|
+
- aip:web:domain
|
|
32
|
+
- aip:key:algorithm:multibase
|
|
33
|
+
|
|
34
|
+
Raises InvalidIdentifier on malformed input.
|
|
35
|
+
"""
|
|
36
|
+
if not s or not s.startswith("aip:"):
|
|
37
|
+
raise InvalidIdentifier(f"not an AIP identifier: {s!r}")
|
|
38
|
+
|
|
39
|
+
parts = s.split(":", maxsplit=2)
|
|
40
|
+
if len(parts) < 3:
|
|
41
|
+
raise InvalidIdentifier(f"too few segments: {s!r}")
|
|
42
|
+
|
|
43
|
+
_, scheme, remainder = parts
|
|
44
|
+
|
|
45
|
+
if scheme == "web":
|
|
46
|
+
# remainder might be "domain/path" or just "domain"
|
|
47
|
+
if "/" in remainder:
|
|
48
|
+
domain, path = remainder.split("/", maxsplit=1)
|
|
49
|
+
return cls(scheme="web", domain=domain, path=path)
|
|
50
|
+
else:
|
|
51
|
+
if not remainder:
|
|
52
|
+
raise InvalidIdentifier(f"empty domain: {s!r}")
|
|
53
|
+
return cls(scheme="web", domain=remainder)
|
|
54
|
+
|
|
55
|
+
elif scheme == "key":
|
|
56
|
+
# remainder is "algorithm:multibase"
|
|
57
|
+
key_parts = remainder.split(":", maxsplit=1)
|
|
58
|
+
if len(key_parts) != 2 or not key_parts[1]:
|
|
59
|
+
raise InvalidIdentifier(
|
|
60
|
+
f"key identifier must be aip:key:algorithm:multibase, got: {s!r}"
|
|
61
|
+
)
|
|
62
|
+
algorithm, multibase = key_parts
|
|
63
|
+
return cls(
|
|
64
|
+
scheme="key", algorithm=algorithm, public_key_multibase=multibase
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
else:
|
|
68
|
+
raise InvalidIdentifier(f"unknown scheme: {scheme!r}")
|
|
69
|
+
|
|
70
|
+
def resolution_url(self) -> Optional[str]:
|
|
71
|
+
"""Return the HTTPS resolution URL for web identifiers, or None for key identifiers."""
|
|
72
|
+
if self.scheme != "web":
|
|
73
|
+
return None
|
|
74
|
+
if self.path:
|
|
75
|
+
return f"https://{self.domain}/.well-known/aip/{self.path}.json"
|
|
76
|
+
return f"https://{self.domain}/.well-known/aip.json"
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
if self.scheme == "web":
|
|
80
|
+
if self.path:
|
|
81
|
+
return f"aip:web:{self.domain}/{self.path}"
|
|
82
|
+
return f"aip:web:{self.domain}"
|
|
83
|
+
elif self.scheme == "key":
|
|
84
|
+
return f"aip:key:{self.algorithm}:{self.public_key_multibase}"
|
|
85
|
+
return f"aip:{self.scheme}"
|
aip_mcp/__init__.py
ADDED
aip_mcp/error.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
def aip_error_response(code: str, message: str, status: int) -> dict:
|
|
2
|
+
return {
|
|
3
|
+
"error": {"code": code, "message": message},
|
|
4
|
+
"status": status,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
def token_missing():
|
|
8
|
+
return aip_error_response("aip_token_missing", "No AIP token provided", 401)
|
|
9
|
+
|
|
10
|
+
def token_malformed(detail: str):
|
|
11
|
+
return aip_error_response("aip_token_malformed", detail, 401)
|
|
12
|
+
|
|
13
|
+
def signature_invalid():
|
|
14
|
+
return aip_error_response("aip_signature_invalid", "Signature verification failed", 401)
|
|
15
|
+
|
|
16
|
+
def token_expired():
|
|
17
|
+
return aip_error_response("aip_token_expired", "Token has expired", 401)
|
|
18
|
+
|
|
19
|
+
def scope_insufficient(scope: str):
|
|
20
|
+
return aip_error_response("aip_scope_insufficient", f"Token does not authorize {scope}", 403)
|
aip_mcp/middleware.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from aip_token.compact import CompactToken
|
|
2
|
+
from aip_token.error import TokenError
|
|
3
|
+
|
|
4
|
+
def extract_token(headers: dict) -> str | None:
|
|
5
|
+
"""Extract AIP token from headers. Case-insensitive."""
|
|
6
|
+
for key, value in headers.items():
|
|
7
|
+
if key.lower() == "x-aip-token":
|
|
8
|
+
return value
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
def detect_mode(token: str) -> str:
|
|
12
|
+
"""Detect compact (JWT) or chained (Biscuit) mode."""
|
|
13
|
+
if token.startswith("eyJ"):
|
|
14
|
+
return "compact"
|
|
15
|
+
return "chained"
|
|
16
|
+
|
|
17
|
+
def verify_request(headers: dict, public_key_bytes: bytes, required_scope: str):
|
|
18
|
+
"""Verify AIP token from request headers.
|
|
19
|
+
|
|
20
|
+
For compact mode: verifies JWT and checks scope.
|
|
21
|
+
For chained mode: verifies Biscuit chain and authorizes tool.
|
|
22
|
+
|
|
23
|
+
Returns verified token on success, raises TokenError on failure.
|
|
24
|
+
"""
|
|
25
|
+
from aip_mcp import error as err
|
|
26
|
+
|
|
27
|
+
token_str = extract_token(headers)
|
|
28
|
+
if not token_str:
|
|
29
|
+
raise TokenError("No AIP token provided", "aip_token_missing")
|
|
30
|
+
|
|
31
|
+
mode = detect_mode(token_str)
|
|
32
|
+
|
|
33
|
+
if mode == "compact":
|
|
34
|
+
verified = CompactToken.verify(token_str, public_key_bytes)
|
|
35
|
+
if not verified.has_scope(required_scope):
|
|
36
|
+
raise TokenError(
|
|
37
|
+
f"Token does not authorize {required_scope}",
|
|
38
|
+
"aip_scope_insufficient"
|
|
39
|
+
)
|
|
40
|
+
return verified
|
|
41
|
+
else:
|
|
42
|
+
# Chained mode - try to import, may not be available
|
|
43
|
+
try:
|
|
44
|
+
from aip_token.chained import ChainedToken
|
|
45
|
+
# public_key_bytes needs to be exactly 32 bytes for chained
|
|
46
|
+
pk = bytes(public_key_bytes)
|
|
47
|
+
chained = ChainedToken.from_base64(token_str, pk)
|
|
48
|
+
chained.authorize(required_scope, pk)
|
|
49
|
+
return chained
|
|
50
|
+
except ImportError:
|
|
51
|
+
raise TokenError(
|
|
52
|
+
"Chained mode requires biscuit-python",
|
|
53
|
+
"aip_token_malformed"
|
|
54
|
+
)
|
aip_token/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""aip_token: JWT-based token issuance and verification for the Agent Identity Protocol."""
|
|
2
|
+
|
|
3
|
+
from aip_token.claims import AipClaims
|
|
4
|
+
from aip_token.compact import CompactToken
|
|
5
|
+
from aip_token.delegation import DelegationBlock
|
|
6
|
+
from aip_token.error import TokenError
|
|
7
|
+
from aip_token.policy import SimplePolicy
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from aip_token.chained import ChainedToken
|
|
11
|
+
except ImportError:
|
|
12
|
+
ChainedToken = None # type: ignore[assignment,misc]
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AipClaims",
|
|
16
|
+
"ChainedToken",
|
|
17
|
+
"CompactToken",
|
|
18
|
+
"DelegationBlock",
|
|
19
|
+
"SimplePolicy",
|
|
20
|
+
"TokenError",
|
|
21
|
+
]
|
aip_token/chained.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""ChainedToken: Biscuit-backed delegation tokens for AIP.
|
|
2
|
+
|
|
3
|
+
Bridges AIP's Ed25519 key pairs with biscuit-python to provide
|
|
4
|
+
cryptographically attenuated delegation chains.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
from biscuit_auth import (
|
|
12
|
+
AuthorizerBuilder,
|
|
13
|
+
Biscuit,
|
|
14
|
+
BiscuitBuilder,
|
|
15
|
+
BlockBuilder,
|
|
16
|
+
Fact,
|
|
17
|
+
PrivateKey as BiscuitPrivateKey,
|
|
18
|
+
PublicKey as BiscuitPublicKey,
|
|
19
|
+
Rule,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from aip_core.crypto import KeyPair
|
|
23
|
+
from aip_token.error import TokenError
|
|
24
|
+
|
|
25
|
+
# biscuit-python >= 0.4 requires an Algorithm argument for from_bytes();
|
|
26
|
+
# older versions do not. Detect at import time.
|
|
27
|
+
try:
|
|
28
|
+
from biscuit_auth import Algorithm as _Algorithm
|
|
29
|
+
_ED25519 = _Algorithm.Ed25519
|
|
30
|
+
except ImportError:
|
|
31
|
+
_ED25519 = None # type: ignore[assignment]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _biscuit_private_key(keypair: KeyPair) -> BiscuitPrivateKey:
|
|
35
|
+
"""Convert an AIP KeyPair's private key to a biscuit PrivateKey."""
|
|
36
|
+
raw = keypair.private_key_bytes()
|
|
37
|
+
if _ED25519 is not None:
|
|
38
|
+
return BiscuitPrivateKey.from_bytes(raw, _ED25519)
|
|
39
|
+
return BiscuitPrivateKey.from_bytes(raw) # type: ignore[call-arg]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _biscuit_public_key(raw_bytes: bytes) -> BiscuitPublicKey:
|
|
43
|
+
"""Convert raw 32-byte public key bytes to a biscuit PublicKey."""
|
|
44
|
+
if _ED25519 is not None:
|
|
45
|
+
return BiscuitPublicKey.from_bytes(raw_bytes, _ED25519)
|
|
46
|
+
return BiscuitPublicKey.from_bytes(raw_bytes) # type: ignore[call-arg]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ChainedToken:
|
|
50
|
+
"""A Biscuit-based chained delegation token.
|
|
51
|
+
|
|
52
|
+
Supports creating authority tokens, delegating with attenuation,
|
|
53
|
+
serialization/deserialization, and authorization checks.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
biscuit: Biscuit,
|
|
59
|
+
issuer_str: str,
|
|
60
|
+
max_depth_val: int,
|
|
61
|
+
root_pubkey_bytes: bytes | None = None,
|
|
62
|
+
depth: int = 0,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._biscuit = biscuit
|
|
65
|
+
self._issuer = issuer_str
|
|
66
|
+
self._max_depth = max_depth_val
|
|
67
|
+
self._root_pubkey_bytes = root_pubkey_bytes
|
|
68
|
+
self._depth = depth
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def create_authority(
|
|
72
|
+
issuer: str,
|
|
73
|
+
scopes: list[str],
|
|
74
|
+
budget_cents: int | None,
|
|
75
|
+
max_depth: int,
|
|
76
|
+
ttl_seconds: int,
|
|
77
|
+
keypair: KeyPair,
|
|
78
|
+
) -> ChainedToken:
|
|
79
|
+
"""Create a new authority (root) token."""
|
|
80
|
+
biscuit_private = _biscuit_private_key(keypair)
|
|
81
|
+
|
|
82
|
+
expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=ttl_seconds)
|
|
83
|
+
|
|
84
|
+
facts = f'identity("{issuer}");\n'
|
|
85
|
+
for scope in scopes:
|
|
86
|
+
facts += f'right("{scope}");\n'
|
|
87
|
+
if budget_cents is not None:
|
|
88
|
+
facts += f"budget({budget_cents});\n"
|
|
89
|
+
facts += f"max_depth({max_depth});\n"
|
|
90
|
+
facts += f'check if time($t), $t <= {expiry.strftime("%Y-%m-%dT%H:%M:%SZ")};\n'
|
|
91
|
+
|
|
92
|
+
builder = BiscuitBuilder(facts)
|
|
93
|
+
biscuit = builder.build(biscuit_private)
|
|
94
|
+
|
|
95
|
+
return ChainedToken(
|
|
96
|
+
biscuit,
|
|
97
|
+
issuer,
|
|
98
|
+
max_depth,
|
|
99
|
+
keypair.public_key_bytes(),
|
|
100
|
+
depth=0,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def delegate(
|
|
104
|
+
self,
|
|
105
|
+
delegator: str,
|
|
106
|
+
delegate: str,
|
|
107
|
+
scopes: list[str],
|
|
108
|
+
budget_cents: int | None,
|
|
109
|
+
context: str,
|
|
110
|
+
) -> ChainedToken:
|
|
111
|
+
"""Create a delegated (attenuated) token by appending a block.
|
|
112
|
+
|
|
113
|
+
Raises TokenError if context is empty or delegation depth is exceeded.
|
|
114
|
+
"""
|
|
115
|
+
if not context or not context.strip():
|
|
116
|
+
raise TokenError("Context must be non-empty", "aip_token_malformed")
|
|
117
|
+
|
|
118
|
+
if self._depth >= self._max_depth:
|
|
119
|
+
raise TokenError("Delegation depth exceeded", "aip_depth_exceeded")
|
|
120
|
+
|
|
121
|
+
checks = f'delegator("{delegator}");\n'
|
|
122
|
+
checks += f'delegate("{delegate}");\n'
|
|
123
|
+
checks += f'context("{context}");\n'
|
|
124
|
+
for scope in scopes:
|
|
125
|
+
checks += f'check if right("{scope}");\n'
|
|
126
|
+
if budget_cents is not None:
|
|
127
|
+
checks += f"check if budget($b), $b <= {budget_cents};\n"
|
|
128
|
+
|
|
129
|
+
block = BlockBuilder(checks)
|
|
130
|
+
new_biscuit = self._biscuit.append(block)
|
|
131
|
+
|
|
132
|
+
return ChainedToken(
|
|
133
|
+
new_biscuit,
|
|
134
|
+
self._issuer,
|
|
135
|
+
self._max_depth,
|
|
136
|
+
self._root_pubkey_bytes,
|
|
137
|
+
depth=self._depth + 1,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def authorize(self, tool: str, root_public_key_bytes: bytes) -> None:
|
|
141
|
+
"""Verify the token chain and authorize a specific tool invocation.
|
|
142
|
+
|
|
143
|
+
Re-verifies from serialized form to ensure the full chain is valid.
|
|
144
|
+
Raises on authorization failure.
|
|
145
|
+
"""
|
|
146
|
+
biscuit_pubkey = _biscuit_public_key(root_public_key_bytes)
|
|
147
|
+
serialized = self._biscuit.to_base64()
|
|
148
|
+
verified = Biscuit.from_base64(serialized, biscuit_pubkey)
|
|
149
|
+
|
|
150
|
+
now = datetime.now(tz=timezone.utc)
|
|
151
|
+
auth_code = (
|
|
152
|
+
f'tool("{tool}");\n'
|
|
153
|
+
f'time({now.strftime("%Y-%m-%dT%H:%M:%SZ")});\n'
|
|
154
|
+
f"depth({self._depth});\n"
|
|
155
|
+
f'allow if right("{tool}");\n'
|
|
156
|
+
)
|
|
157
|
+
authorizer = AuthorizerBuilder(auth_code).build(verified)
|
|
158
|
+
authorizer.authorize()
|
|
159
|
+
|
|
160
|
+
def to_base64(self) -> str:
|
|
161
|
+
"""Serialize the token to a URL-safe base64 string."""
|
|
162
|
+
return self._biscuit.to_base64()
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def from_base64(s: str, root_public_key_bytes: bytes) -> ChainedToken:
|
|
166
|
+
"""Deserialize a token from base64 and verify against the root public key."""
|
|
167
|
+
biscuit_pubkey = _biscuit_public_key(root_public_key_bytes)
|
|
168
|
+
biscuit = Biscuit.from_base64(s, biscuit_pubkey)
|
|
169
|
+
|
|
170
|
+
# Extract issuer and max_depth by parsing the authority block source.
|
|
171
|
+
# We avoid using Authorizer here because it triggers all checks
|
|
172
|
+
# (tool, time) which have no matching facts during deserialization.
|
|
173
|
+
issuer = "unknown"
|
|
174
|
+
max_depth = 3
|
|
175
|
+
try:
|
|
176
|
+
source = biscuit.block_source(0)
|
|
177
|
+
if source:
|
|
178
|
+
for line in source.split("\n"):
|
|
179
|
+
line = line.strip().rstrip(";")
|
|
180
|
+
if line.startswith("identity("):
|
|
181
|
+
# Extract string between quotes: identity("aip:web:...")
|
|
182
|
+
start = line.index('"') + 1
|
|
183
|
+
end = line.rindex('"')
|
|
184
|
+
issuer = line[start:end]
|
|
185
|
+
elif line.startswith("max_depth("):
|
|
186
|
+
val = line[len("max_depth("):-1]
|
|
187
|
+
max_depth = int(val)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# Depth is block_count - 1 (authority block is block 0)
|
|
192
|
+
depth = biscuit.block_count() - 1
|
|
193
|
+
|
|
194
|
+
return ChainedToken(biscuit, issuer, max_depth, root_public_key_bytes, depth=depth)
|
|
195
|
+
|
|
196
|
+
def issuer(self) -> str:
|
|
197
|
+
"""Return the token issuer identity."""
|
|
198
|
+
return self._issuer
|
|
199
|
+
|
|
200
|
+
def max_depth(self) -> int:
|
|
201
|
+
"""Return the maximum delegation depth."""
|
|
202
|
+
return self._max_depth
|
|
203
|
+
|
|
204
|
+
def current_depth(self) -> int:
|
|
205
|
+
"""Return the current delegation depth (0 for authority tokens)."""
|
|
206
|
+
return self._depth
|
aip_token/claims.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""AIP token claims model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AipClaims(BaseModel):
|
|
9
|
+
"""Claims carried inside an AIP compact token (JWT)."""
|
|
10
|
+
|
|
11
|
+
iss: str
|
|
12
|
+
"""Issuer AIP identifier."""
|
|
13
|
+
|
|
14
|
+
sub: str
|
|
15
|
+
"""Subject AIP identifier."""
|
|
16
|
+
|
|
17
|
+
scope: list[str]
|
|
18
|
+
"""List of scope strings (e.g. 'tool:search')."""
|
|
19
|
+
|
|
20
|
+
budget_usd: float | None = None
|
|
21
|
+
"""Optional budget ceiling in USD."""
|
|
22
|
+
|
|
23
|
+
max_depth: int = 0
|
|
24
|
+
"""Maximum delegation depth. 0 means no further delegation allowed."""
|
|
25
|
+
|
|
26
|
+
iat: int
|
|
27
|
+
"""Issued-at unix timestamp."""
|
|
28
|
+
|
|
29
|
+
exp: int
|
|
30
|
+
"""Expiry unix timestamp."""
|
aip_token/compact.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Compact token format: JWT with EdDSA (Ed25519) signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
7
|
+
|
|
8
|
+
from aip_core.crypto import KeyPair
|
|
9
|
+
from aip_token.claims import AipClaims
|
|
10
|
+
from aip_token.error import TokenError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CompactToken:
|
|
14
|
+
"""AIP compact token backed by a JWT with EdDSA signatures."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, claims: AipClaims) -> None:
|
|
17
|
+
self._claims = claims
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def claims(self) -> AipClaims:
|
|
21
|
+
"""Return the verified claims."""
|
|
22
|
+
return self._claims
|
|
23
|
+
|
|
24
|
+
def has_scope(self, scope: str) -> bool:
|
|
25
|
+
"""Return True if *scope* is present in the token's scope list."""
|
|
26
|
+
return scope in self._claims.scope
|
|
27
|
+
|
|
28
|
+
# -- static / class methods -------------------------------------------
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def create(claims: AipClaims, keypair: KeyPair) -> str:
|
|
32
|
+
"""Create a signed compact token (JWT) from *claims* using *keypair*.
|
|
33
|
+
|
|
34
|
+
The resulting JWT carries header ``{"alg": "EdDSA", "typ": "aip+jwt"}``.
|
|
35
|
+
"""
|
|
36
|
+
payload = claims.model_dump(mode="json")
|
|
37
|
+
token: str = jwt.encode(
|
|
38
|
+
payload,
|
|
39
|
+
keypair._private_key,
|
|
40
|
+
algorithm="EdDSA",
|
|
41
|
+
headers={"typ": "aip+jwt"},
|
|
42
|
+
)
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def verify(token_str: str, public_key_bytes: bytes) -> CompactToken:
|
|
47
|
+
"""Verify *token_str* against the given raw public key bytes.
|
|
48
|
+
|
|
49
|
+
Returns a ``CompactToken`` with the decoded claims on success.
|
|
50
|
+
Raises ``TokenError`` on any failure (expired, bad signature, malformed).
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
54
|
+
payload = jwt.decode(
|
|
55
|
+
token_str,
|
|
56
|
+
public_key,
|
|
57
|
+
algorithms=["EdDSA"],
|
|
58
|
+
)
|
|
59
|
+
claims = AipClaims(**payload)
|
|
60
|
+
return CompactToken(claims)
|
|
61
|
+
except jwt.ExpiredSignatureError:
|
|
62
|
+
raise TokenError.token_expired()
|
|
63
|
+
except jwt.InvalidSignatureError:
|
|
64
|
+
raise TokenError.signature_invalid()
|
|
65
|
+
except jwt.DecodeError as exc:
|
|
66
|
+
raise TokenError.token_malformed(str(exc))
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
raise TokenError.token_malformed(str(exc))
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def decode_header(token_str: str) -> dict:
|
|
72
|
+
"""Decode the JWT header without verifying the signature."""
|
|
73
|
+
return jwt.get_unverified_header(token_str)
|
aip_token/delegation.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Delegation block metadata for AIP chained tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class DelegationBlock:
|
|
10
|
+
"""Metadata describing a single delegation step in a chained token."""
|
|
11
|
+
|
|
12
|
+
delegator: str
|
|
13
|
+
delegate: str
|
|
14
|
+
scopes: list[str]
|
|
15
|
+
budget_cents: int | None
|
|
16
|
+
context: str
|
aip_token/error.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Token error types for the AIP token package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TokenError(Exception):
|
|
7
|
+
"""Base error for AIP token operations."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, code: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.code = code
|
|
12
|
+
|
|
13
|
+
def error_code(self) -> str:
|
|
14
|
+
return self.code
|
|
15
|
+
|
|
16
|
+
# -- convenience constructors ------------------------------------------
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def token_missing(cls) -> TokenError:
|
|
20
|
+
return cls("token is missing", "token_missing")
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def token_malformed(cls, detail: str = "") -> TokenError:
|
|
24
|
+
msg = "token is malformed"
|
|
25
|
+
if detail:
|
|
26
|
+
msg = f"{msg}: {detail}"
|
|
27
|
+
return cls(msg, "token_malformed")
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def signature_invalid(cls) -> TokenError:
|
|
31
|
+
return cls("signature is invalid", "signature_invalid")
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def identity_unresolvable(cls, identity: str = "") -> TokenError:
|
|
35
|
+
msg = "identity cannot be resolved"
|
|
36
|
+
if identity:
|
|
37
|
+
msg = f"{msg}: {identity}"
|
|
38
|
+
return cls(msg, "identity_unresolvable")
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def token_expired(cls) -> TokenError:
|
|
42
|
+
return cls("token has expired", "token_expired")
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def scope_insufficient(cls, scope: str = "") -> TokenError:
|
|
46
|
+
msg = "insufficient scope"
|
|
47
|
+
if scope:
|
|
48
|
+
msg = f"{msg}: {scope}"
|
|
49
|
+
return cls(msg, "scope_insufficient")
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def budget_exceeded(cls) -> TokenError:
|
|
53
|
+
return cls("budget ceiling exceeded", "budget_exceeded")
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def depth_exceeded(cls) -> TokenError:
|
|
57
|
+
return cls("delegation depth exceeded", "depth_exceeded")
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def key_revoked(cls) -> TokenError:
|
|
61
|
+
return cls("signing key has been revoked", "key_revoked")
|
aip_token/policy.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Simple policy profiles for Biscuit-based AIP tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SimplePolicy:
|
|
11
|
+
"""A simple policy that generates Datalog checks for Biscuit tokens.
|
|
12
|
+
|
|
13
|
+
Budget is expressed in integer cents (Biscuit has no float support).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
tools: list[str] = field(default_factory=list)
|
|
17
|
+
budget_cents: int | None = None
|
|
18
|
+
max_depth: int | None = None
|
|
19
|
+
ttl_seconds: int | None = None
|
|
20
|
+
|
|
21
|
+
def to_datalog(self) -> str:
|
|
22
|
+
rules: list[str] = []
|
|
23
|
+
if self.tools:
|
|
24
|
+
tool_list = ", ".join(f'"{t}"' for t in self.tools)
|
|
25
|
+
rules.append(f"check if tool($tool), [{tool_list}].contains($tool);")
|
|
26
|
+
if self.budget_cents is not None:
|
|
27
|
+
rules.append(f"check if budget($b), $b <= {self.budget_cents};")
|
|
28
|
+
if self.max_depth is not None:
|
|
29
|
+
rules.append(f"check if depth($d), $d <= {self.max_depth};")
|
|
30
|
+
if self.ttl_seconds is not None:
|
|
31
|
+
expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=self.ttl_seconds)
|
|
32
|
+
rules.append(
|
|
33
|
+
f'check if time($t), $t <= {expiry.strftime("%Y-%m-%dT%H:%M:%SZ")};'
|
|
34
|
+
)
|
|
35
|
+
return "\n".join(rules)
|