the-ment 0.1.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.
- the_ment-0.1.0/LICENSE +21 -0
- the_ment-0.1.0/PKG-INFO +149 -0
- the_ment-0.1.0/README.md +123 -0
- the_ment-0.1.0/core/__init__.py +32 -0
- the_ment-0.1.0/core/agent_core.py +263 -0
- the_ment-0.1.0/core/api.py +137 -0
- the_ment-0.1.0/core/environment.py +186 -0
- the_ment-0.1.0/ment/__init__.py +31 -0
- the_ment-0.1.0/ment/agent_core.py +23 -0
- the_ment-0.1.0/ment/api.py +5 -0
- the_ment-0.1.0/ment/environment.py +5 -0
- the_ment-0.1.0/pyproject.toml +44 -0
- the_ment-0.1.0/setup.cfg +4 -0
- the_ment-0.1.0/tests/test_agent_core.py +75 -0
- the_ment-0.1.0/tests/test_api.py +73 -0
- the_ment-0.1.0/tests/test_environment.py +92 -0
- the_ment-0.1.0/the_ment.egg-info/PKG-INFO +149 -0
- the_ment-0.1.0/the_ment.egg-info/SOURCES.txt +19 -0
- the_ment-0.1.0/the_ment.egg-info/dependency_links.txt +1 -0
- the_ment-0.1.0/the_ment.egg-info/requires.txt +5 -0
- the_ment-0.1.0/the_ment.egg-info/top_level.txt +2 -0
the_ment-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Environment 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.
|
the_ment-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: the-ment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A multi-agent environment with identities and simple usage
|
|
5
|
+
Author: The Environment Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agent,identity,cryptography,ed25519,authentication,multi-agent
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: cryptography>=46.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# The Environment
|
|
28
|
+
|
|
29
|
+
Portable, pseudonymous agent identities with Ed25519 cryptographic signatures.
|
|
30
|
+
|
|
31
|
+
A minimal protocol for secure agent discovery, authentication, and communication. Perfect for multi-agent systems, AI agents, and decentralized applications.
|
|
32
|
+
|
|
33
|
+
## Why The Environment?
|
|
34
|
+
|
|
35
|
+
- **Secure** - Ed25519 signatures, AES-GCM encryption, Scrypt KDF
|
|
36
|
+
- **Simple** - Small core with one cryptography dependency
|
|
37
|
+
- **Portable** - Export/import identities as bundles
|
|
38
|
+
- **Flexible** - Support any serializable Python data
|
|
39
|
+
- **Fast** - In-memory environment for real-time discovery
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install the-ment
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Create an Identity
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ment import create_identity
|
|
51
|
+
|
|
52
|
+
identity = create_identity("my-password")
|
|
53
|
+
print(f"Agent ID: {identity.id}")
|
|
54
|
+
unlocked = identity.unlock("my-password")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Sign and Verify
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from ment import create_identity, verify_signature
|
|
61
|
+
|
|
62
|
+
identity = create_identity("my-password")
|
|
63
|
+
unlocked = identity.unlock("my-password")
|
|
64
|
+
signature = unlocked.sign({"action": "transfer"})
|
|
65
|
+
|
|
66
|
+
is_valid = verify_signature(identity.public_key, {"action": "transfer"}, signature)
|
|
67
|
+
print(f"Valid: {is_valid}") # True
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Multi-Agent Communication
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from ment import create_identity, Environment
|
|
74
|
+
|
|
75
|
+
agent_a = create_identity("password-a")
|
|
76
|
+
agent_b = create_identity("password-b")
|
|
77
|
+
|
|
78
|
+
env = Environment()
|
|
79
|
+
env.join(agent_a.id, capabilities=["code"])
|
|
80
|
+
env.join(agent_b.id, capabilities=["search"])
|
|
81
|
+
|
|
82
|
+
env.send(agent_a.id, {"text": "Hello!"}, target_id=agent_b.id)
|
|
83
|
+
messages = env.receive(agent_b.id)
|
|
84
|
+
print(messages[0].content) # {"text": "Hello!"}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For authenticated delivery, register the sender's public key and send a signed
|
|
88
|
+
`Message` through `env.send_message(...)`. The Environment verifies the
|
|
89
|
+
identity binding and signature before delivery. Unsigned messages remain
|
|
90
|
+
available for open discovery and communication.
|
|
91
|
+
|
|
92
|
+
## Examples
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python examples/basic_usage.py
|
|
96
|
+
python examples/multi_agent_chat.py
|
|
97
|
+
python examples/signed_messages.py
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Minimal API
|
|
101
|
+
|
|
102
|
+
The Environment can also run as a small in-memory HTTP service:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from ment import serve
|
|
106
|
+
|
|
107
|
+
serve(host="127.0.0.1", port=8765)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Available endpoints:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
POST /join
|
|
114
|
+
GET /discover
|
|
115
|
+
POST /send
|
|
116
|
+
GET /receive
|
|
117
|
+
POST /heartbeat
|
|
118
|
+
POST /leave
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The API has no database or graphical interface. It exposes the same temporary
|
|
122
|
+
Environment through JSON so agents in different processes can communicate.
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
- **Identity**: Random Ed25519 key pair, password-protected via AES-GCM
|
|
127
|
+
- **Signatures**: Ed25519 for message authentication
|
|
128
|
+
- **Environment**: In-memory agent discovery and messaging with automatic verification of supplied signatures
|
|
129
|
+
- **Bundles**: Portable export/import of full identity
|
|
130
|
+
|
|
131
|
+
## Security
|
|
132
|
+
|
|
133
|
+
- Ed25519 signatures (fast, secure, small)
|
|
134
|
+
- AES-GCM encryption for private keys
|
|
135
|
+
- Scrypt KDF for password protection
|
|
136
|
+
- Constant-time comparison for ID verification
|
|
137
|
+
- Input length limits on identity and message source fields
|
|
138
|
+
|
|
139
|
+
## Testing
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python -m pytest tests/ -v
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
20/20 tests passing
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
the_ment-0.1.0/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# The Environment
|
|
2
|
+
|
|
3
|
+
Portable, pseudonymous agent identities with Ed25519 cryptographic signatures.
|
|
4
|
+
|
|
5
|
+
A minimal protocol for secure agent discovery, authentication, and communication. Perfect for multi-agent systems, AI agents, and decentralized applications.
|
|
6
|
+
|
|
7
|
+
## Why The Environment?
|
|
8
|
+
|
|
9
|
+
- **Secure** - Ed25519 signatures, AES-GCM encryption, Scrypt KDF
|
|
10
|
+
- **Simple** - Small core with one cryptography dependency
|
|
11
|
+
- **Portable** - Export/import identities as bundles
|
|
12
|
+
- **Flexible** - Support any serializable Python data
|
|
13
|
+
- **Fast** - In-memory environment for real-time discovery
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install the-ment
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Create an Identity
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from ment import create_identity
|
|
25
|
+
|
|
26
|
+
identity = create_identity("my-password")
|
|
27
|
+
print(f"Agent ID: {identity.id}")
|
|
28
|
+
unlocked = identity.unlock("my-password")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Sign and Verify
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from ment import create_identity, verify_signature
|
|
35
|
+
|
|
36
|
+
identity = create_identity("my-password")
|
|
37
|
+
unlocked = identity.unlock("my-password")
|
|
38
|
+
signature = unlocked.sign({"action": "transfer"})
|
|
39
|
+
|
|
40
|
+
is_valid = verify_signature(identity.public_key, {"action": "transfer"}, signature)
|
|
41
|
+
print(f"Valid: {is_valid}") # True
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Multi-Agent Communication
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ment import create_identity, Environment
|
|
48
|
+
|
|
49
|
+
agent_a = create_identity("password-a")
|
|
50
|
+
agent_b = create_identity("password-b")
|
|
51
|
+
|
|
52
|
+
env = Environment()
|
|
53
|
+
env.join(agent_a.id, capabilities=["code"])
|
|
54
|
+
env.join(agent_b.id, capabilities=["search"])
|
|
55
|
+
|
|
56
|
+
env.send(agent_a.id, {"text": "Hello!"}, target_id=agent_b.id)
|
|
57
|
+
messages = env.receive(agent_b.id)
|
|
58
|
+
print(messages[0].content) # {"text": "Hello!"}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For authenticated delivery, register the sender's public key and send a signed
|
|
62
|
+
`Message` through `env.send_message(...)`. The Environment verifies the
|
|
63
|
+
identity binding and signature before delivery. Unsigned messages remain
|
|
64
|
+
available for open discovery and communication.
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python examples/basic_usage.py
|
|
70
|
+
python examples/multi_agent_chat.py
|
|
71
|
+
python examples/signed_messages.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Minimal API
|
|
75
|
+
|
|
76
|
+
The Environment can also run as a small in-memory HTTP service:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from ment import serve
|
|
80
|
+
|
|
81
|
+
serve(host="127.0.0.1", port=8765)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Available endpoints:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
POST /join
|
|
88
|
+
GET /discover
|
|
89
|
+
POST /send
|
|
90
|
+
GET /receive
|
|
91
|
+
POST /heartbeat
|
|
92
|
+
POST /leave
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The API has no database or graphical interface. It exposes the same temporary
|
|
96
|
+
Environment through JSON so agents in different processes can communicate.
|
|
97
|
+
|
|
98
|
+
## Architecture
|
|
99
|
+
|
|
100
|
+
- **Identity**: Random Ed25519 key pair, password-protected via AES-GCM
|
|
101
|
+
- **Signatures**: Ed25519 for message authentication
|
|
102
|
+
- **Environment**: In-memory agent discovery and messaging with automatic verification of supplied signatures
|
|
103
|
+
- **Bundles**: Portable export/import of full identity
|
|
104
|
+
|
|
105
|
+
## Security
|
|
106
|
+
|
|
107
|
+
- Ed25519 signatures (fast, secure, small)
|
|
108
|
+
- AES-GCM encryption for private keys
|
|
109
|
+
- Scrypt KDF for password protection
|
|
110
|
+
- Constant-time comparison for ID verification
|
|
111
|
+
- Input length limits on identity and message source fields
|
|
112
|
+
|
|
113
|
+
## Testing
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python -m pytest tests/ -v
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
20/20 tests passing
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Agent ID - Portable, pseudonymous agent identities with Ed25519 signatures."""
|
|
2
|
+
|
|
3
|
+
from core.agent_core import (
|
|
4
|
+
DEFAULT_H,
|
|
5
|
+
AgentIdentity,
|
|
6
|
+
Message,
|
|
7
|
+
UnlockedIdentity,
|
|
8
|
+
create_identity,
|
|
9
|
+
derive_id,
|
|
10
|
+
verify_identity,
|
|
11
|
+
verify_signature,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from core.environment import Environment, Presence
|
|
15
|
+
from core.api import create_server, serve
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_H",
|
|
19
|
+
"AgentIdentity",
|
|
20
|
+
"Message",
|
|
21
|
+
"UnlockedIdentity",
|
|
22
|
+
"create_identity",
|
|
23
|
+
"derive_id",
|
|
24
|
+
"verify_identity",
|
|
25
|
+
"verify_signature",
|
|
26
|
+
"Environment",
|
|
27
|
+
"Presence",
|
|
28
|
+
"create_server",
|
|
29
|
+
"serve",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Small, password-protected identities and signed agent messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
import json
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature, InvalidTag
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SALT_SIZE = 16
|
|
24
|
+
NONCE_SIZE = 12
|
|
25
|
+
SCRYPT_N = 2**14
|
|
26
|
+
SCRYPT_R = 8
|
|
27
|
+
SCRYPT_P = 1
|
|
28
|
+
MAX_PASSWORD_LENGTH = 1024
|
|
29
|
+
MAX_STRING_LENGTH = 4096
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_text(value: str, name: str, max_length: int = MAX_STRING_LENGTH) -> str:
|
|
33
|
+
if not isinstance(value, str) or not value:
|
|
34
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
35
|
+
if len(value) > max_length:
|
|
36
|
+
raise ValueError(f"{name} exceeds maximum length of {max_length}")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hash_parts(*parts: str) -> str:
|
|
41
|
+
encoded = bytearray()
|
|
42
|
+
for part in parts:
|
|
43
|
+
value = _require_text(part, "hash part").encode("utf-8")
|
|
44
|
+
encoded.extend(len(value).to_bytes(8, "big"))
|
|
45
|
+
encoded.extend(value)
|
|
46
|
+
return sha256(encoded).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _canonical_bytes(value: Any) -> bytes:
|
|
50
|
+
return json.dumps(
|
|
51
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
52
|
+
).encode("utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _public_key_bytes(public_key: str) -> bytes:
|
|
56
|
+
try:
|
|
57
|
+
raw = bytes.fromhex(_require_text(public_key, "public_key"))
|
|
58
|
+
Ed25519PublicKey.from_public_bytes(raw)
|
|
59
|
+
return raw
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
raise ValueError("public_key must be a valid Ed25519 public key") from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _derive_unlock_key(password: str, salt: bytes) -> bytes:
|
|
65
|
+
_require_text(password, "password", MAX_PASSWORD_LENGTH)
|
|
66
|
+
return Scrypt(
|
|
67
|
+
salt=salt,
|
|
68
|
+
length=32,
|
|
69
|
+
n=SCRYPT_N,
|
|
70
|
+
r=SCRYPT_R,
|
|
71
|
+
p=SCRYPT_P,
|
|
72
|
+
).derive(password.encode("utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT_H = sha256(b"agent-id-protocol-v1").hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def derive_id(public_key: str, H: str = DEFAULT_H) -> str:
|
|
79
|
+
"""Derive the stable public ID from the protocol base and public key."""
|
|
80
|
+
_public_key_bytes(public_key)
|
|
81
|
+
return _hash_parts(H, public_key)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def verify_identity(identity_id: str, public_key: str, H: str = DEFAULT_H) -> bool:
|
|
85
|
+
"""Verify that an ID belongs to a specific public key."""
|
|
86
|
+
_require_text(identity_id, "identity_id")
|
|
87
|
+
derived = derive_id(public_key, H)
|
|
88
|
+
return hmac.compare_digest(derived, identity_id)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AgentIdentity:
|
|
93
|
+
"""Public identity plus an encrypted private signing key."""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
public_key: str
|
|
97
|
+
encrypted_private_key: str = field(repr=False)
|
|
98
|
+
salt: str = field(repr=False)
|
|
99
|
+
nonce: str = field(repr=False)
|
|
100
|
+
H: str = DEFAULT_H
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def public(self) -> dict[str, str]:
|
|
104
|
+
return {"H": self.H, "id": self.id, "public_key": self.public_key}
|
|
105
|
+
|
|
106
|
+
def bundle(self) -> dict[str, str]:
|
|
107
|
+
"""Return the data needed to store and restore this identity."""
|
|
108
|
+
return {
|
|
109
|
+
**self.public,
|
|
110
|
+
"encrypted_private_key": self.encrypted_private_key,
|
|
111
|
+
"salt": self.salt,
|
|
112
|
+
"nonce": self.nonce,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_bundle(cls, bundle: dict[str, str]) -> "AgentIdentity":
|
|
117
|
+
identity = cls(
|
|
118
|
+
id=bundle["id"],
|
|
119
|
+
public_key=bundle["public_key"],
|
|
120
|
+
encrypted_private_key=bundle["encrypted_private_key"],
|
|
121
|
+
salt=bundle["salt"],
|
|
122
|
+
nonce=bundle["nonce"],
|
|
123
|
+
H=bundle["H"],
|
|
124
|
+
)
|
|
125
|
+
if not verify_identity(identity.id, identity.public_key, identity.H):
|
|
126
|
+
raise ValueError("identity does not match its public key")
|
|
127
|
+
return identity
|
|
128
|
+
|
|
129
|
+
def _aad(self) -> bytes:
|
|
130
|
+
return _canonical_bytes(self.public)
|
|
131
|
+
|
|
132
|
+
def _private_key(self, password: str) -> Ed25519PrivateKey:
|
|
133
|
+
try:
|
|
134
|
+
key = _derive_unlock_key(password, bytes.fromhex(self.salt))
|
|
135
|
+
raw = AESGCM(key).decrypt(
|
|
136
|
+
bytes.fromhex(self.nonce),
|
|
137
|
+
bytes.fromhex(self.encrypted_private_key),
|
|
138
|
+
self._aad(),
|
|
139
|
+
)
|
|
140
|
+
private_key = Ed25519PrivateKey.from_private_bytes(raw)
|
|
141
|
+
if private_key.public_key().public_bytes(
|
|
142
|
+
serialization.Encoding.Raw,
|
|
143
|
+
serialization.PublicFormat.Raw,
|
|
144
|
+
).hex() != self.public_key:
|
|
145
|
+
raise ValueError("private key does not match identity")
|
|
146
|
+
return private_key
|
|
147
|
+
except (InvalidTag, ValueError, TypeError):
|
|
148
|
+
raise ValueError("invalid password or identity bundle") from None
|
|
149
|
+
|
|
150
|
+
def unlock(self, password: str) -> "UnlockedIdentity":
|
|
151
|
+
"""Unlock the private key once for repeated agent operations."""
|
|
152
|
+
return UnlockedIdentity(self.id, self.public_key, self._private_key(password))
|
|
153
|
+
|
|
154
|
+
def sign(self, value: Any, password: str) -> str:
|
|
155
|
+
"""Sign data with one-off password unlocking (compatibility API)."""
|
|
156
|
+
return self.unlock(password).sign(value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class UnlockedIdentity:
|
|
161
|
+
"""In-memory signing session created by AgentIdentity.unlock."""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
public_key: str
|
|
165
|
+
_private_key: Ed25519PrivateKey = field(repr=False)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def public(self) -> dict[str, str]:
|
|
169
|
+
return {"id": self.id, "public_key": self.public_key}
|
|
170
|
+
|
|
171
|
+
def sign(self, value: Any) -> str:
|
|
172
|
+
"""Sign data without asking for the password again."""
|
|
173
|
+
return self._private_key.sign(_canonical_bytes(value)).hex()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_identity(password: str, H: str = DEFAULT_H) -> AgentIdentity:
|
|
177
|
+
"""Create a random signing key protected by a password."""
|
|
178
|
+
_require_text(password, "password")
|
|
179
|
+
private_key = Ed25519PrivateKey.generate()
|
|
180
|
+
public_key = private_key.public_key().public_bytes(
|
|
181
|
+
serialization.Encoding.Raw,
|
|
182
|
+
serialization.PublicFormat.Raw,
|
|
183
|
+
).hex()
|
|
184
|
+
private_raw = private_key.private_bytes(
|
|
185
|
+
serialization.Encoding.Raw,
|
|
186
|
+
serialization.PrivateFormat.Raw,
|
|
187
|
+
serialization.NoEncryption(),
|
|
188
|
+
)
|
|
189
|
+
salt = secrets.token_bytes(SALT_SIZE)
|
|
190
|
+
nonce = secrets.token_bytes(NONCE_SIZE)
|
|
191
|
+
identity_id = derive_id(public_key, H)
|
|
192
|
+
temporary = AgentIdentity(
|
|
193
|
+
id=identity_id,
|
|
194
|
+
public_key=public_key,
|
|
195
|
+
encrypted_private_key="",
|
|
196
|
+
salt=salt.hex(),
|
|
197
|
+
nonce=nonce.hex(),
|
|
198
|
+
H=H,
|
|
199
|
+
)
|
|
200
|
+
unlock_key = _derive_unlock_key(password, salt)
|
|
201
|
+
encrypted = AESGCM(unlock_key).encrypt(nonce, private_raw, temporary._aad())
|
|
202
|
+
return replace(temporary, encrypted_private_key=encrypted.hex())
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def verify_signature(public_key: str, value: Any, signature: str) -> bool:
|
|
206
|
+
"""Verify an Ed25519 signature over canonical data."""
|
|
207
|
+
try:
|
|
208
|
+
_public_key_bytes(public_key)
|
|
209
|
+
Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key)).verify(
|
|
210
|
+
bytes.fromhex(signature), _canonical_bytes(value)
|
|
211
|
+
)
|
|
212
|
+
return True
|
|
213
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Message:
|
|
219
|
+
"""A generic signed or unsigned message; target_id is optional."""
|
|
220
|
+
|
|
221
|
+
source_id: str
|
|
222
|
+
content: Any
|
|
223
|
+
target_id: str | None = None
|
|
224
|
+
t: int = field(default_factory=lambda: time.time_ns() // 1_000_000)
|
|
225
|
+
signature: str | None = None
|
|
226
|
+
|
|
227
|
+
def __post_init__(self) -> None:
|
|
228
|
+
_require_text(self.source_id, "source_id")
|
|
229
|
+
if self.target_id is not None:
|
|
230
|
+
_require_text(self.target_id, "target_id")
|
|
231
|
+
|
|
232
|
+
def _unsigned(self) -> dict[str, Any]:
|
|
233
|
+
return {
|
|
234
|
+
"source_id": self.source_id,
|
|
235
|
+
"target_id": self.target_id,
|
|
236
|
+
"content": self.content,
|
|
237
|
+
"t": self.t,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def sign(
|
|
241
|
+
self,
|
|
242
|
+
identity: AgentIdentity | UnlockedIdentity,
|
|
243
|
+
password: str | None = None,
|
|
244
|
+
) -> "Message":
|
|
245
|
+
if self.source_id != identity.id:
|
|
246
|
+
raise ValueError("message source does not match identity")
|
|
247
|
+
if isinstance(identity, UnlockedIdentity):
|
|
248
|
+
if password is not None:
|
|
249
|
+
raise ValueError("password is not used by an unlocked identity")
|
|
250
|
+
signer = identity
|
|
251
|
+
else:
|
|
252
|
+
if password is None:
|
|
253
|
+
raise ValueError("password is required to unlock the identity")
|
|
254
|
+
signer = identity.unlock(password)
|
|
255
|
+
return replace(self, signature=signer.sign(self._unsigned()))
|
|
256
|
+
|
|
257
|
+
def verify(self, public_key: str) -> bool:
|
|
258
|
+
return self.signature is not None and verify_signature(
|
|
259
|
+
public_key, self._unsigned(), self.signature
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def to_dict(self) -> dict[str, Any]:
|
|
263
|
+
return {**self._unsigned(), "signature": self.signature}
|