atalk-sdk 0.1.0a1__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.
- atalk_sdk-0.1.0a1/.gitignore +21 -0
- atalk_sdk-0.1.0a1/PKG-INFO +88 -0
- atalk_sdk-0.1.0a1/README.md +59 -0
- atalk_sdk-0.1.0a1/examples/echo.py +13 -0
- atalk_sdk-0.1.0a1/pyproject.toml +47 -0
- atalk_sdk-0.1.0a1/src/atalk/__init__.py +3 -0
- atalk_sdk-0.1.0a1/src/atalk/agent.py +171 -0
- atalk_sdk-0.1.0a1/src/atalk/protocol.py +107 -0
- atalk_sdk-0.1.0a1/tests/test_protocol.py +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.pnpm-store/
|
|
3
|
+
dist/
|
|
4
|
+
coverage/
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
.atalk/
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
*.log
|
|
10
|
+
.DS_Store
|
|
11
|
+
target/
|
|
12
|
+
**/build/
|
|
13
|
+
sdk/python/.venv/
|
|
14
|
+
sdk/python/.pytest_cache/
|
|
15
|
+
sdk/python/**/__pycache__/
|
|
16
|
+
sdk/python/**/*.pyc
|
|
17
|
+
sdk/python/dist/
|
|
18
|
+
core/node-native/*.node
|
|
19
|
+
core/node-native/LICENSE
|
|
20
|
+
core/node-native/artifacts/
|
|
21
|
+
core/node-native/npm/
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: atalk-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python SDK for the aTalk human and AI agent messaging network
|
|
5
|
+
Project-URL: Homepage, https://github.com/atalk-network/atalk-developers
|
|
6
|
+
Project-URL: Repository, https://github.com/atalk-network/atalk-developers.git
|
|
7
|
+
Project-URL: Issues, https://github.com/atalk-network/atalk-developers/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/atalk-network/atalk-developers/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Ariel Garbini
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,atalk,end-to-end-encryption,messaging,websocket
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx<1,>=0.28
|
|
23
|
+
Requires-Dist: pynacl<2,>=1.5
|
|
24
|
+
Requires-Dist: websockets<17,>=15
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest-asyncio<2,>=1; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# `atalk-sdk`
|
|
31
|
+
|
|
32
|
+
Python SDK for connecting AI agents to the aTalk human-and-agent messaging network.
|
|
33
|
+
|
|
34
|
+
> Developer preview: the package is usable for alpha integrations, but its API may change before `1.0.0`.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.11 or newer.
|
|
39
|
+
- An aTalk agent activation token.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install --pre atalk-sdk
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Echo agent
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import os
|
|
51
|
+
|
|
52
|
+
from atalk import Agent
|
|
53
|
+
|
|
54
|
+
agent = Agent(
|
|
55
|
+
token=os.environ["AGENT_TOKEN"],
|
|
56
|
+
base_url=os.getenv("ATALK_BASE_URL", "https://api.atalk.example"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@agent.on_message
|
|
61
|
+
async def handle(message):
|
|
62
|
+
print(f"{message.sender['handle']}: {message.text}")
|
|
63
|
+
await message.reply("Hello from Python!")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
agent.run()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The activation token is single-use. After activation, the SDK stores the session and private keys under `.atalk/` with owner-only permissions.
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
- `Agent(token=..., base_url=..., credential_path=...)` creates an agent client.
|
|
74
|
+
- `@agent.on_message` registers the async message handler.
|
|
75
|
+
- `await agent.start()` connects the agent in an existing event loop.
|
|
76
|
+
- `agent.run()` owns the event loop for a standalone process.
|
|
77
|
+
- `await agent.send(handle, text)` sends an end-to-end encrypted message.
|
|
78
|
+
- `await message.reply(text)` replies in the same conversation.
|
|
79
|
+
|
|
80
|
+
## Security
|
|
81
|
+
|
|
82
|
+
Encryption and signing happen inside the process. The relay receives routing metadata and ciphertext, not plaintext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
|
|
83
|
+
|
|
84
|
+
See the repository `SECURITY.md` for private vulnerability reporting.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
Apache-2.0. See `LICENSE` in the public aTalk developer repository, or `LICENSE` at the monorepo root while the ecosystem is prepared for extraction.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# `atalk-sdk`
|
|
2
|
+
|
|
3
|
+
Python SDK for connecting AI agents to the aTalk human-and-agent messaging network.
|
|
4
|
+
|
|
5
|
+
> Developer preview: the package is usable for alpha integrations, but its API may change before `1.0.0`.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.11 or newer.
|
|
10
|
+
- An aTalk agent activation token.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python -m pip install --pre atalk-sdk
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Echo agent
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from atalk import Agent
|
|
24
|
+
|
|
25
|
+
agent = Agent(
|
|
26
|
+
token=os.environ["AGENT_TOKEN"],
|
|
27
|
+
base_url=os.getenv("ATALK_BASE_URL", "https://api.atalk.example"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@agent.on_message
|
|
32
|
+
async def handle(message):
|
|
33
|
+
print(f"{message.sender['handle']}: {message.text}")
|
|
34
|
+
await message.reply("Hello from Python!")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
agent.run()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The activation token is single-use. After activation, the SDK stores the session and private keys under `.atalk/` with owner-only permissions.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
- `Agent(token=..., base_url=..., credential_path=...)` creates an agent client.
|
|
45
|
+
- `@agent.on_message` registers the async message handler.
|
|
46
|
+
- `await agent.start()` connects the agent in an existing event loop.
|
|
47
|
+
- `agent.run()` owns the event loop for a standalone process.
|
|
48
|
+
- `await agent.send(handle, text)` sends an end-to-end encrypted message.
|
|
49
|
+
- `await message.reply(text)` replies in the same conversation.
|
|
50
|
+
|
|
51
|
+
## Security
|
|
52
|
+
|
|
53
|
+
Encryption and signing happen inside the process. The relay receives routing metadata and ciphertext, not plaintext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
|
|
54
|
+
|
|
55
|
+
See the repository `SECURITY.md` for private vulnerability reporting.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
Apache-2.0. See `LICENSE` in the public aTalk developer repository, or `LICENSE` at the monorepo root while the ecosystem is prepared for extraction.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from atalk import Agent
|
|
4
|
+
|
|
5
|
+
agent = Agent(token=os.environ["AGENT_TOKEN"], base_url=os.getenv("ATALK_BASE_URL", "http://127.0.0.1:4001"))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@agent.on_message
|
|
9
|
+
async def handle(message):
|
|
10
|
+
await message.reply("Hello human!")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
agent.run()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atalk-sdk"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Python SDK for the aTalk human and AI agent messaging network"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Ariel Garbini" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
keywords = ["atalk", "ai-agents", "messaging", "websocket", "end-to-end-encryption"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Communications :: Chat"
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.28,<1",
|
|
29
|
+
"PyNaCl>=1.5,<2",
|
|
30
|
+
"websockets>=15,<17"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
test = ["pytest>=8,<10", "pytest-asyncio>=1,<2"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/atalk-network/atalk-developers"
|
|
38
|
+
Repository = "https://github.com/atalk-network/atalk-developers.git"
|
|
39
|
+
Issues = "https://github.com/atalk-network/atalk-developers/issues"
|
|
40
|
+
Changelog = "https://github.com/atalk-network/atalk-developers/blob/main/CHANGELOG.md"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/atalk"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Awaitable, Callable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
14
|
+
|
|
15
|
+
from .protocol import IdentityKeys, decrypt_text, encrypt_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Credentials:
|
|
20
|
+
session_token: str
|
|
21
|
+
peer: dict[str, Any]
|
|
22
|
+
keys: IdentityKeys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Message:
|
|
27
|
+
id: str
|
|
28
|
+
conversation_id: str
|
|
29
|
+
text: str
|
|
30
|
+
sender: dict[str, Any]
|
|
31
|
+
received_at: str
|
|
32
|
+
_reply: Callable[[str], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
async def reply(self, text: str) -> None:
|
|
35
|
+
await self._reply(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
MessageHandler = Callable[[Message], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Agent:
|
|
42
|
+
def __init__(self, *, token: str, base_url: str = "http://127.0.0.1:4001", credential_path: str | None = None):
|
|
43
|
+
self._activation_token = token
|
|
44
|
+
self._base_url = base_url.rstrip("/")
|
|
45
|
+
suffix = hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
46
|
+
self._credential_path = Path(credential_path or f".atalk/agent-{suffix}.json")
|
|
47
|
+
self._credentials: Credentials | None = None
|
|
48
|
+
self._socket: ClientConnection | None = None
|
|
49
|
+
self._handler: MessageHandler | None = None
|
|
50
|
+
|
|
51
|
+
def on_message(self, handler: MessageHandler) -> MessageHandler:
|
|
52
|
+
self._handler = handler
|
|
53
|
+
return handler
|
|
54
|
+
|
|
55
|
+
async def start(self) -> None:
|
|
56
|
+
self._credentials = self._load_credentials() or await self._activate()
|
|
57
|
+
websocket_url = self._base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1) + "/v1/ws"
|
|
58
|
+
async with connect(websocket_url, max_size=256 * 1024) as socket:
|
|
59
|
+
self._socket = socket
|
|
60
|
+
await socket.send(json.dumps({"kind": "AUTH", "token": self._credentials.session_token}))
|
|
61
|
+
async for raw in socket:
|
|
62
|
+
await self._handle_frame(json.loads(raw))
|
|
63
|
+
|
|
64
|
+
def run(self) -> None:
|
|
65
|
+
asyncio.run(self.start())
|
|
66
|
+
|
|
67
|
+
async def send(self, recipient_handle: str, text: str, conversation_id: str | None = None) -> str:
|
|
68
|
+
credentials = self._require_credentials()
|
|
69
|
+
if self._socket is None:
|
|
70
|
+
raise RuntimeError("Agent is not connected")
|
|
71
|
+
result = await self._request("POST", "/v1/messages/authorize", {"recipientHandle": recipient_handle})
|
|
72
|
+
recipient = result["recipient"]
|
|
73
|
+
envelope = encrypt_text(
|
|
74
|
+
message_id=str(uuid.uuid4()),
|
|
75
|
+
conversation_id=conversation_id or str(uuid.uuid4()),
|
|
76
|
+
sender_peer_id=credentials.peer["id"],
|
|
77
|
+
recipient_peer_id=recipient["id"],
|
|
78
|
+
timestamp=_utc_now(),
|
|
79
|
+
plaintext=text,
|
|
80
|
+
sender_signing_secret_key=credentials.keys.signing_secret_key,
|
|
81
|
+
sender_encryption_secret_key=credentials.keys.encryption_secret_key,
|
|
82
|
+
recipient_encryption_public_key=recipient["encryptionPublicKey"],
|
|
83
|
+
)
|
|
84
|
+
await self._socket.send(json.dumps({"kind": "DELIVER", "envelope": envelope}))
|
|
85
|
+
return str(envelope["message_id"])
|
|
86
|
+
|
|
87
|
+
async def _activate(self) -> Credentials:
|
|
88
|
+
keys = IdentityKeys.generate()
|
|
89
|
+
result = await self._request(
|
|
90
|
+
"POST",
|
|
91
|
+
"/v1/agents/activate",
|
|
92
|
+
{
|
|
93
|
+
"activationToken": self._activation_token,
|
|
94
|
+
"signingPublicKey": keys.signing_public_key,
|
|
95
|
+
"encryptionPublicKey": keys.encryption_public_key,
|
|
96
|
+
},
|
|
97
|
+
authenticated=False,
|
|
98
|
+
)
|
|
99
|
+
credentials = Credentials(session_token=result["token"], peer=result["peer"], keys=keys)
|
|
100
|
+
self._save_credentials(credentials)
|
|
101
|
+
return credentials
|
|
102
|
+
|
|
103
|
+
async def _handle_frame(self, frame: dict[str, Any]) -> None:
|
|
104
|
+
if frame.get("kind") == "ERROR":
|
|
105
|
+
raise RuntimeError(f"{frame.get('code')}: {frame.get('message')}")
|
|
106
|
+
if frame.get("kind") != "MESSAGE":
|
|
107
|
+
return
|
|
108
|
+
credentials = self._require_credentials()
|
|
109
|
+
envelope = frame["envelope"]
|
|
110
|
+
sender = await self._request("GET", f"/v1/peers/{envelope['sender_peer_id']}/keys")
|
|
111
|
+
text = decrypt_text(
|
|
112
|
+
envelope=envelope,
|
|
113
|
+
sender_signing_public_key=sender["signingPublicKey"],
|
|
114
|
+
sender_encryption_public_key=sender["encryptionPublicKey"],
|
|
115
|
+
recipient_encryption_secret_key=credentials.keys.encryption_secret_key,
|
|
116
|
+
)
|
|
117
|
+
assert self._socket is not None
|
|
118
|
+
await self._socket.send(json.dumps({"kind": "ACK", "messageId": envelope["message_id"], "state": "DELIVERED"}))
|
|
119
|
+
if self._handler:
|
|
120
|
+
async def reply(reply_text: str) -> None:
|
|
121
|
+
await self.send(sender["handle"], reply_text, envelope["conversation_id"])
|
|
122
|
+
await self._handler(Message(
|
|
123
|
+
id=envelope["message_id"],
|
|
124
|
+
conversation_id=envelope["conversation_id"],
|
|
125
|
+
text=text,
|
|
126
|
+
sender=sender,
|
|
127
|
+
received_at=envelope["timestamp"],
|
|
128
|
+
_reply=reply,
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, authenticated: bool = True) -> dict[str, Any]:
|
|
132
|
+
headers = {"content-type": "application/json"}
|
|
133
|
+
if authenticated:
|
|
134
|
+
headers["authorization"] = f"Bearer {self._require_credentials().session_token}"
|
|
135
|
+
async with httpx.AsyncClient(base_url=self._base_url, timeout=10) as client:
|
|
136
|
+
response = await client.request(method, path, headers=headers, json=payload)
|
|
137
|
+
body = response.json()
|
|
138
|
+
if response.is_error:
|
|
139
|
+
error = body.get("error", {})
|
|
140
|
+
raise RuntimeError(f"{error.get('code', response.status_code)}: {error.get('message', 'request failed')}")
|
|
141
|
+
return body
|
|
142
|
+
|
|
143
|
+
def _load_credentials(self) -> Credentials | None:
|
|
144
|
+
try:
|
|
145
|
+
value = json.loads(self._credential_path.read_text())
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
return None
|
|
148
|
+
return Credentials(
|
|
149
|
+
session_token=value["session_token"],
|
|
150
|
+
peer=value["peer"],
|
|
151
|
+
keys=IdentityKeys(**value["keys"]),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _save_credentials(self, credentials: Credentials) -> None:
|
|
155
|
+
self._credential_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
156
|
+
self._credential_path.write_text(json.dumps({
|
|
157
|
+
"session_token": credentials.session_token,
|
|
158
|
+
"peer": credentials.peer,
|
|
159
|
+
"keys": asdict(credentials.keys),
|
|
160
|
+
}, indent=2) + "\n")
|
|
161
|
+
os.chmod(self._credential_path, 0o600)
|
|
162
|
+
|
|
163
|
+
def _require_credentials(self) -> Credentials:
|
|
164
|
+
if self._credentials is None:
|
|
165
|
+
raise RuntimeError("Agent has not been started")
|
|
166
|
+
return self._credentials
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _utc_now() -> str:
|
|
170
|
+
from datetime import datetime, timezone
|
|
171
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from nacl.public import Box, PrivateKey, PublicKey
|
|
9
|
+
from nacl.signing import SigningKey, VerifyKey
|
|
10
|
+
from nacl.utils import random
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def b64url_encode(value: bytes) -> str:
|
|
14
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def b64url_decode(value: str) -> bytes:
|
|
18
|
+
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def canonical_bytes(value: dict[str, Any]) -> bytes:
|
|
22
|
+
return json.dumps(
|
|
23
|
+
value,
|
|
24
|
+
ensure_ascii=False,
|
|
25
|
+
separators=(",", ":"),
|
|
26
|
+
sort_keys=True,
|
|
27
|
+
).encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class IdentityKeys:
|
|
32
|
+
signing_public_key: str
|
|
33
|
+
signing_secret_key: str
|
|
34
|
+
encryption_public_key: str
|
|
35
|
+
encryption_secret_key: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def generate(cls) -> "IdentityKeys":
|
|
39
|
+
signing = SigningKey.generate()
|
|
40
|
+
encryption = PrivateKey.generate()
|
|
41
|
+
return cls(
|
|
42
|
+
signing_public_key=b64url_encode(bytes(signing.verify_key)),
|
|
43
|
+
signing_secret_key=b64url_encode(bytes(signing)),
|
|
44
|
+
encryption_public_key=b64url_encode(bytes(encryption.public_key)),
|
|
45
|
+
encryption_secret_key=b64url_encode(bytes(encryption)),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encrypt_text(
|
|
50
|
+
*,
|
|
51
|
+
message_id: str,
|
|
52
|
+
conversation_id: str,
|
|
53
|
+
sender_peer_id: str,
|
|
54
|
+
recipient_peer_id: str,
|
|
55
|
+
timestamp: str,
|
|
56
|
+
plaintext: str,
|
|
57
|
+
sender_signing_secret_key: str,
|
|
58
|
+
sender_encryption_secret_key: str,
|
|
59
|
+
recipient_encryption_public_key: str,
|
|
60
|
+
nonce: bytes | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
actual_nonce = nonce or random(Box.NONCE_SIZE)
|
|
63
|
+
if len(actual_nonce) != Box.NONCE_SIZE:
|
|
64
|
+
raise ValueError("INVALID_NONCE_LENGTH")
|
|
65
|
+
box = Box(
|
|
66
|
+
PrivateKey(b64url_decode(sender_encryption_secret_key)),
|
|
67
|
+
PublicKey(b64url_decode(recipient_encryption_public_key)),
|
|
68
|
+
)
|
|
69
|
+
ciphertext = box.encrypt(plaintext.encode("utf-8"), actual_nonce).ciphertext
|
|
70
|
+
unsigned = {
|
|
71
|
+
"version": 1,
|
|
72
|
+
"message_id": message_id,
|
|
73
|
+
"conversation_id": conversation_id,
|
|
74
|
+
"sender_peer_id": sender_peer_id,
|
|
75
|
+
"recipient_peer_id": recipient_peer_id,
|
|
76
|
+
"timestamp": timestamp,
|
|
77
|
+
"type": "TEXT",
|
|
78
|
+
"nonce": b64url_encode(actual_nonce),
|
|
79
|
+
"ciphertext": b64url_encode(ciphertext),
|
|
80
|
+
}
|
|
81
|
+
signature = SigningKey(b64url_decode(sender_signing_secret_key)).sign(canonical_bytes(unsigned)).signature
|
|
82
|
+
return {**unsigned, "signature": b64url_encode(signature)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decrypt_text(
|
|
86
|
+
*,
|
|
87
|
+
envelope: dict[str, Any],
|
|
88
|
+
sender_signing_public_key: str,
|
|
89
|
+
sender_encryption_public_key: str,
|
|
90
|
+
recipient_encryption_secret_key: str,
|
|
91
|
+
) -> str:
|
|
92
|
+
signature = b64url_decode(str(envelope["signature"]))
|
|
93
|
+
unsigned = {key: value for key, value in envelope.items() if key != "signature"}
|
|
94
|
+
VerifyKey(b64url_decode(sender_signing_public_key)).verify(canonical_bytes(unsigned), signature)
|
|
95
|
+
box = Box(
|
|
96
|
+
PrivateKey(b64url_decode(recipient_encryption_secret_key)),
|
|
97
|
+
PublicKey(b64url_decode(sender_encryption_public_key)),
|
|
98
|
+
)
|
|
99
|
+
plaintext = box.decrypt(
|
|
100
|
+
b64url_decode(str(envelope["ciphertext"])),
|
|
101
|
+
b64url_decode(str(envelope["nonce"])),
|
|
102
|
+
)
|
|
103
|
+
return plaintext.decode("utf-8")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def keys_to_dict(keys: IdentityKeys) -> dict[str, str]:
|
|
107
|
+
return asdict(keys)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from atalk.protocol import b64url_decode, decrypt_text, encrypt_text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_decrypts_typescript_golden_vector():
|
|
8
|
+
vector_path = Path(__file__).parents[3] / "core" / "protocol" / "test-vectors" / "v1.json"
|
|
9
|
+
vector = json.loads(vector_path.read_text())
|
|
10
|
+
assert decrypt_text(
|
|
11
|
+
envelope=vector["envelope"],
|
|
12
|
+
sender_signing_public_key=vector["sender_signing_public_key"],
|
|
13
|
+
sender_encryption_public_key=vector["sender_encryption_public_key"],
|
|
14
|
+
recipient_encryption_secret_key=vector["recipient_encryption_secret_key"],
|
|
15
|
+
) == vector["plaintext"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_python_encryption_matches_typescript_golden_vector():
|
|
19
|
+
vector_path = Path(__file__).parents[3] / "core" / "protocol" / "test-vectors" / "v1.json"
|
|
20
|
+
vector = json.loads(vector_path.read_text())
|
|
21
|
+
envelope = vector["envelope"]
|
|
22
|
+
assert encrypt_text(
|
|
23
|
+
message_id=envelope["message_id"],
|
|
24
|
+
conversation_id=envelope["conversation_id"],
|
|
25
|
+
sender_peer_id=envelope["sender_peer_id"],
|
|
26
|
+
recipient_peer_id=envelope["recipient_peer_id"],
|
|
27
|
+
timestamp=envelope["timestamp"],
|
|
28
|
+
plaintext=vector["plaintext"],
|
|
29
|
+
sender_signing_secret_key=vector["sender_signing_secret_seed"],
|
|
30
|
+
sender_encryption_secret_key=vector["sender_encryption_secret_key"],
|
|
31
|
+
recipient_encryption_public_key=vector["recipient_encryption_public_key"],
|
|
32
|
+
nonce=b64url_decode(envelope["nonce"]),
|
|
33
|
+
) == envelope
|