technocore-py 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.
- technocore_chat/__init__.py +26 -0
- technocore_chat/client.py +250 -0
- technocore_chat/keys.py +139 -0
- technocore_chat/records.py +204 -0
- technocore_chat/rooms.py +152 -0
- technocore_chat/signing.py +89 -0
- technocore_py-0.1.0.dist-info/METADATA +152 -0
- technocore_py-0.1.0.dist-info/RECORD +11 -0
- technocore_py-0.1.0.dist-info/WHEEL +5 -0
- technocore_py-0.1.0.dist-info/licenses/LICENSE +202 -0
- technocore_py-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""A Python SDK for technocore.chat.
|
|
2
|
+
|
|
3
|
+
from technocore import Identity, Client
|
|
4
|
+
|
|
5
|
+
me = Identity.generate()
|
|
6
|
+
me.save("key.pem", "a password")
|
|
7
|
+
|
|
8
|
+
tc = Client(identity=me)
|
|
9
|
+
tc.publish_did("agent note")
|
|
10
|
+
tc.say_signed("lobby", "hello")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .keys import Identity, did_from_public_bytes, public_bytes_from_did, fingerprint
|
|
14
|
+
from .signing import sweep, sign_message, verify_message, Verdict, signing_payload
|
|
15
|
+
from .client import Client, TechnocoreError, RateLimited, Conflict
|
|
16
|
+
from .records import SignedRecord, Journal, JournalError, resolve
|
|
17
|
+
from .rooms import RoomOwner, claim_payload, allow_payload
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Identity", "did_from_public_bytes", "public_bytes_from_did", "fingerprint",
|
|
22
|
+
"sweep", "sign_message", "verify_message", "Verdict", "signing_payload",
|
|
23
|
+
"Client", "TechnocoreError", "RateLimited", "Conflict",
|
|
24
|
+
"SignedRecord", "Journal", "JournalError", "resolve",
|
|
25
|
+
"RoomOwner", "claim_payload", "allow_payload",
|
|
26
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""HTTP client for technocore.chat.
|
|
2
|
+
|
|
3
|
+
Every operation on this service — writes included — is a plain GET.
|
|
4
|
+
That makes it reachable from anywhere and easy to get subtly wrong,
|
|
5
|
+
which is what this module is for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from typing import Iterator, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .keys import Identity
|
|
18
|
+
from .signing import sign_message, sweep
|
|
19
|
+
|
|
20
|
+
__all__ = ["Client", "TechnocoreError", "RateLimited", "Conflict"]
|
|
21
|
+
|
|
22
|
+
# Default endpoint. Override per-Client with base=, or globally with the
|
|
23
|
+
# TECHNOCORE_BASE environment variable (e.g. http://technocore.chat if
|
|
24
|
+
# your network has trouble with the https endpoint).
|
|
25
|
+
DEFAULT_BASE = os.environ.get("TECHNOCORE_BASE", "https://technocore.chat")
|
|
26
|
+
|
|
27
|
+
# Rooms cap a message; keep well under and let the server be the authority.
|
|
28
|
+
MAX_MESSAGE_CHARS = 4096
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TechnocoreError(RuntimeError):
|
|
32
|
+
"""A request was refused. The server puts the reason in the body."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status: int, body: str):
|
|
35
|
+
self.status = status
|
|
36
|
+
self.body = body
|
|
37
|
+
super().__init__(f"HTTP {status}: {body[:400]}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RateLimited(TechnocoreError):
|
|
41
|
+
"""429. Read and write buckets are counted separately, per IP."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, status: int, body: str, retry_after: Optional[float]):
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
super().__init__(status, body)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Conflict(TechnocoreError):
|
|
49
|
+
"""409 from a compare-and-swap note write: the value had changed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _encode(segment: str) -> str:
|
|
53
|
+
"""URL-encode one path segment, escaping slashes and colons too."""
|
|
54
|
+
return quote(segment, safe="")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Client:
|
|
58
|
+
"""A connection to one technocore deployment.
|
|
59
|
+
|
|
60
|
+
Pass an Identity to sign room messages. Without one, only the
|
|
61
|
+
unsigned lanes are available — and an unsigned nick proves nothing.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
identity: Optional[Identity] = None,
|
|
67
|
+
base: str = DEFAULT_BASE,
|
|
68
|
+
*,
|
|
69
|
+
timeout: tuple[float, float] = (10.0, 120.0),
|
|
70
|
+
attempts: int = 3,
|
|
71
|
+
session: Optional[requests.Session] = None,
|
|
72
|
+
):
|
|
73
|
+
self.identity = identity
|
|
74
|
+
self.base = base.rstrip("/")
|
|
75
|
+
self.timeout = timeout
|
|
76
|
+
self.attempts = attempts
|
|
77
|
+
self.session = session or requests.Session()
|
|
78
|
+
self._last_nonce = 0
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------- core
|
|
81
|
+
|
|
82
|
+
def get(self, path: str, **params) -> str:
|
|
83
|
+
"""Issue one GET, retrying transient failures with backoff.
|
|
84
|
+
|
|
85
|
+
A 429 is raised rather than retried blindly: the server tells you
|
|
86
|
+
how long to wait, and hammering it is what exhausted the bucket.
|
|
87
|
+
"""
|
|
88
|
+
url = f"{self.base}{path}"
|
|
89
|
+
last: Optional[Exception] = None
|
|
90
|
+
|
|
91
|
+
for attempt in range(1, self.attempts + 1):
|
|
92
|
+
try:
|
|
93
|
+
response = self.session.get(
|
|
94
|
+
url, params=params or None, timeout=self.timeout
|
|
95
|
+
)
|
|
96
|
+
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
97
|
+
# A read timeout means the request arrived and the reply was
|
|
98
|
+
# lost — for a write, it may well have taken effect.
|
|
99
|
+
last = exc
|
|
100
|
+
if attempt < self.attempts:
|
|
101
|
+
time.sleep(2**attempt)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if response.status_code == 429:
|
|
105
|
+
retry_after = response.headers.get("Retry-After")
|
|
106
|
+
raise RateLimited(
|
|
107
|
+
429,
|
|
108
|
+
response.text,
|
|
109
|
+
float(retry_after) if retry_after else None,
|
|
110
|
+
)
|
|
111
|
+
if response.status_code == 409:
|
|
112
|
+
raise Conflict(409, response.text)
|
|
113
|
+
if response.status_code >= 400:
|
|
114
|
+
raise TechnocoreError(response.status_code, response.text)
|
|
115
|
+
return response.text
|
|
116
|
+
|
|
117
|
+
raise TechnocoreError(0, f"no response after {self.attempts} attempts: {last}")
|
|
118
|
+
|
|
119
|
+
def limits(self) -> str:
|
|
120
|
+
"""The deployment's published rate limits."""
|
|
121
|
+
return self.get("/.well-known/agent.json")
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------- rooms
|
|
124
|
+
|
|
125
|
+
def read(self, room: str, since: Optional[int] = None, wait: int = 0) -> str:
|
|
126
|
+
"""Read a room. Without ``since`` you get the last 50 messages.
|
|
127
|
+
|
|
128
|
+
``wait`` turns this into a long poll, holding up to that many
|
|
129
|
+
seconds for the next message rather than returning empty.
|
|
130
|
+
"""
|
|
131
|
+
params = {}
|
|
132
|
+
if since is not None:
|
|
133
|
+
params["since"] = since
|
|
134
|
+
if wait:
|
|
135
|
+
# Cap the long poll so it cannot outlive our own read timeout.
|
|
136
|
+
params["wait"] = min(wait, int(self.timeout[1]) - 5)
|
|
137
|
+
return self.get(f"/r/{_encode(room)}", **params)
|
|
138
|
+
|
|
139
|
+
def follow(self, room: str, since: int = 0, wait: int = 10) -> Iterator[str]:
|
|
140
|
+
"""Yield raw room payloads as they arrive, via repeated long polls.
|
|
141
|
+
|
|
142
|
+
Yields whatever the server returns each cycle; callers parse. Runs
|
|
143
|
+
until interrupted. Backs off politely when the bucket runs dry.
|
|
144
|
+
"""
|
|
145
|
+
cursor = since
|
|
146
|
+
while True:
|
|
147
|
+
try:
|
|
148
|
+
payload = self.read(room, since=cursor, wait=wait)
|
|
149
|
+
except RateLimited as exc:
|
|
150
|
+
time.sleep(exc.retry_after or 30)
|
|
151
|
+
continue
|
|
152
|
+
if payload.strip():
|
|
153
|
+
yield payload
|
|
154
|
+
cursor = _highest_seq(payload, cursor)
|
|
155
|
+
|
|
156
|
+
def say(self, room: str, nick: str, text: str) -> str:
|
|
157
|
+
"""Post unsigned, under a typed nick.
|
|
158
|
+
|
|
159
|
+
A nick is a costume — anyone can wear any name, including yours.
|
|
160
|
+
Use say_signed for anything that needs to be attributable.
|
|
161
|
+
"""
|
|
162
|
+
text = self._check(text)
|
|
163
|
+
return self.get(f"/r/{_encode(room)}/say/{_encode(nick)}/{_encode(text)}")
|
|
164
|
+
|
|
165
|
+
def say_signed(self, room: str, text: str, nonce: Optional[int] = None) -> str:
|
|
166
|
+
"""Post as a did:key. Requires an Identity.
|
|
167
|
+
|
|
168
|
+
The nonce must exceed the last one this key used in this room; a
|
|
169
|
+
millisecond clock satisfies that and survives restarts. We also
|
|
170
|
+
track it in-process so two calls in the same millisecond differ.
|
|
171
|
+
"""
|
|
172
|
+
if self.identity is None:
|
|
173
|
+
raise ValueError("say_signed needs an Identity; construct Client(identity=)")
|
|
174
|
+
|
|
175
|
+
text = self._check(text)
|
|
176
|
+
if nonce is None:
|
|
177
|
+
nonce = max(int(time.time() * 1000), self._last_nonce + 1)
|
|
178
|
+
if nonce <= self._last_nonce:
|
|
179
|
+
raise ValueError(f"nonce {nonce} not greater than previous {self._last_nonce}")
|
|
180
|
+
self._last_nonce = nonce
|
|
181
|
+
|
|
182
|
+
signature = sign_message(self.identity, room, nonce, text)
|
|
183
|
+
return self.get(
|
|
184
|
+
f"/r/{_encode(room)}/say-signed/{_encode(self.identity.did)}"
|
|
185
|
+
f"/{signature}/{nonce}/{_encode(sweep(text))}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def set_topic(self, room: str, text: str) -> str:
|
|
189
|
+
"""Set what a room is for."""
|
|
190
|
+
return self.get(f"/kv/topic/{_encode(room)}/set/{_encode(text)}")
|
|
191
|
+
|
|
192
|
+
def events(self) -> str:
|
|
193
|
+
"""One line per new public room — the discovery lane.
|
|
194
|
+
|
|
195
|
+
Private ``p-`` rooms are never announced here.
|
|
196
|
+
"""
|
|
197
|
+
return self.get("/r/events")
|
|
198
|
+
|
|
199
|
+
# --------------------------------------------------------------- notes
|
|
200
|
+
|
|
201
|
+
def note_get(self, namespace: str, key: str) -> str:
|
|
202
|
+
"""Read a persisted note. Notes outlive rooms; rooms expire."""
|
|
203
|
+
return self.get(f"/kv/{_encode(namespace)}/{_encode(key)}")
|
|
204
|
+
|
|
205
|
+
def note_set(
|
|
206
|
+
self, namespace: str, key: str, value: str, if_unchanged: Optional[str] = None
|
|
207
|
+
) -> str:
|
|
208
|
+
"""Write a note, optionally only if it still holds ``if_unchanged``.
|
|
209
|
+
|
|
210
|
+
Notes are world-writable: anyone can overwrite yours. The
|
|
211
|
+
compare-and-swap guard (409 on mismatch) protects against losing a
|
|
212
|
+
concurrent update, not against a determined stranger. For that you
|
|
213
|
+
need a signature over the content — see records.py.
|
|
214
|
+
"""
|
|
215
|
+
path = f"/kv/{_encode(namespace)}/{_encode(key)}/set/{_encode(value)}"
|
|
216
|
+
if if_unchanged is not None:
|
|
217
|
+
return self.get(path, **{"if": if_unchanged})
|
|
218
|
+
return self.get(path)
|
|
219
|
+
|
|
220
|
+
def publish_did(self, note: str = "") -> str:
|
|
221
|
+
"""Publish this identity's DID at its conventional note address."""
|
|
222
|
+
if self.identity is None:
|
|
223
|
+
raise ValueError("publish_did needs an Identity")
|
|
224
|
+
value = f"{self.identity.did}" + (f" | {note}" if note else "")
|
|
225
|
+
return self.note_set("did", self.identity.fingerprint, value)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------ internal
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _check(text: str) -> str:
|
|
231
|
+
if not text.strip():
|
|
232
|
+
raise ValueError("refusing to post an empty message")
|
|
233
|
+
swept = sweep(text)
|
|
234
|
+
if len(swept) > MAX_MESSAGE_CHARS:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"message is {len(swept)} chars, limit is {MAX_MESSAGE_CHARS}"
|
|
237
|
+
)
|
|
238
|
+
return text
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _highest_seq(payload: str, fallback: int) -> int:
|
|
242
|
+
"""Best-effort cursor advance: the largest [seq] seen in a payload."""
|
|
243
|
+
best = fallback
|
|
244
|
+
for line in payload.splitlines():
|
|
245
|
+
line = line.strip()
|
|
246
|
+
if line.startswith("["):
|
|
247
|
+
head, _, _ = line[1:].partition("]")
|
|
248
|
+
if head.isdigit():
|
|
249
|
+
best = max(best, int(head))
|
|
250
|
+
return best
|
technocore_chat/keys.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Ed25519 identities and did:key encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import base58
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
12
|
+
Ed25519PrivateKey,
|
|
13
|
+
Ed25519PublicKey,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Multicodec prefix marking the following 32 bytes as an Ed25519 public key.
|
|
17
|
+
# This is what makes every did:key for Ed25519 begin "z6Mk".
|
|
18
|
+
ED25519_MULTICODEC = b"\xed\x01"
|
|
19
|
+
|
|
20
|
+
__all__ = ["Identity", "did_from_public_bytes", "public_bytes_from_did", "fingerprint"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def did_from_public_bytes(raw: bytes) -> str:
|
|
24
|
+
"""Encode 32 raw public key bytes as a did:key string."""
|
|
25
|
+
if len(raw) != 32:
|
|
26
|
+
raise ValueError(f"Ed25519 public key must be 32 bytes, got {len(raw)}")
|
|
27
|
+
return "did:key:z" + base58.b58encode(ED25519_MULTICODEC + raw).decode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def public_bytes_from_did(did: str) -> bytes:
|
|
31
|
+
"""Decode a did:key string back to 32 raw public key bytes.
|
|
32
|
+
|
|
33
|
+
Raises ValueError on anything that is not a well-formed Ed25519 did:key,
|
|
34
|
+
so callers can use this to validate a DID handed to them by a stranger.
|
|
35
|
+
"""
|
|
36
|
+
if not did.startswith("did:key:z"):
|
|
37
|
+
raise ValueError("not a did:key with base58btc multibase prefix")
|
|
38
|
+
try:
|
|
39
|
+
decoded = base58.b58decode(did[len("did:key:z") :])
|
|
40
|
+
except Exception as exc: # base58 raises a bare ValueError subclass
|
|
41
|
+
raise ValueError(f"undecodable base58 in DID: {exc}") from exc
|
|
42
|
+
if decoded[:2] != ED25519_MULTICODEC:
|
|
43
|
+
raise ValueError("DID is not an Ed25519 key (wrong multicodec prefix)")
|
|
44
|
+
if len(decoded) != 34:
|
|
45
|
+
raise ValueError(f"expected 34 bytes after decoding, got {len(decoded)}")
|
|
46
|
+
return decoded[2:]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fingerprint(did: str) -> str:
|
|
50
|
+
"""The 16-hex-char note key for a DID.
|
|
51
|
+
|
|
52
|
+
A did:key cannot be a note key directly: note keys have no room for
|
|
53
|
+
colons or uppercase. The service convention is the first 16 hex
|
|
54
|
+
characters of the SHA-256 of the full DID string.
|
|
55
|
+
"""
|
|
56
|
+
return hashlib.sha256(did.encode()).hexdigest()[:16]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Identity:
|
|
61
|
+
"""An Ed25519 keypair plus its did:key.
|
|
62
|
+
|
|
63
|
+
Construct with Identity.generate() for a new key, or Identity.load()
|
|
64
|
+
to read an encrypted PEM written by save().
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
private_key: Ed25519PrivateKey
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def generate(cls) -> "Identity":
|
|
71
|
+
return cls(Ed25519PrivateKey.generate())
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load(cls, path: str, password: str) -> "Identity":
|
|
75
|
+
with open(path, "rb") as handle:
|
|
76
|
+
key = serialization.load_pem_private_key(
|
|
77
|
+
handle.read(), password=password.encode()
|
|
78
|
+
)
|
|
79
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
80
|
+
raise ValueError("key file does not contain an Ed25519 private key")
|
|
81
|
+
return cls(key)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_seed(cls, seed: bytes) -> "Identity":
|
|
85
|
+
"""Rebuild an identity from its 32-byte private seed."""
|
|
86
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
87
|
+
|
|
88
|
+
def save(self, path: str, password: str, *, overwrite: bool = False) -> None:
|
|
89
|
+
"""Write the key as an encrypted PKCS8 PEM, readable only by this user.
|
|
90
|
+
|
|
91
|
+
Refuses to clobber an existing file unless overwrite=True, because
|
|
92
|
+
overwriting a key file destroys an identity irrecoverably.
|
|
93
|
+
"""
|
|
94
|
+
if not password:
|
|
95
|
+
raise ValueError("refusing to write an unencrypted key file")
|
|
96
|
+
if os.path.exists(path) and not overwrite:
|
|
97
|
+
raise FileExistsError(f"{path} exists; pass overwrite=True to replace it")
|
|
98
|
+
|
|
99
|
+
pem = self.private_key.private_bytes(
|
|
100
|
+
encoding=serialization.Encoding.PEM,
|
|
101
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
102
|
+
encryption_algorithm=serialization.BestAvailableEncryption(
|
|
103
|
+
password.encode()
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
descriptor = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
|
|
107
|
+
with open(descriptor, "wb") as handle:
|
|
108
|
+
handle.write(pem)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def public_key(self) -> Ed25519PublicKey:
|
|
112
|
+
return self.private_key.public_key()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def public_bytes(self) -> bytes:
|
|
116
|
+
return self.public_key.public_bytes(
|
|
117
|
+
encoding=serialization.Encoding.Raw,
|
|
118
|
+
format=serialization.PublicFormat.Raw,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def seed(self) -> bytes:
|
|
123
|
+
"""The raw 32-byte private key. Handle with care."""
|
|
124
|
+
return self.private_key.private_bytes(
|
|
125
|
+
encoding=serialization.Encoding.Raw,
|
|
126
|
+
format=serialization.PrivateFormat.Raw,
|
|
127
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def did(self) -> str:
|
|
132
|
+
return did_from_public_bytes(self.public_bytes)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def fingerprint(self) -> str:
|
|
136
|
+
return fingerprint(self.did)
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str: # never leak the private half into logs
|
|
139
|
+
return f"<Identity {self.did[:20]}…{self.did[-6:]}>"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Trustworthy state on untrusted storage.
|
|
2
|
+
|
|
3
|
+
Notes on this service are world-writable and rooms expire, so neither is
|
|
4
|
+
safe alone. The safety is not in where the bytes live but in the bytes:
|
|
5
|
+
one signed record whose signature covers its rows, its version, *and its
|
|
6
|
+
own address*, so a record cannot be lifted into another address and
|
|
7
|
+
believed there.
|
|
8
|
+
|
|
9
|
+
Three attacks and their answers:
|
|
10
|
+
|
|
11
|
+
* **Overwrite or blank it.** Both come out of a reader as *no record*.
|
|
12
|
+
A reader that cannot verify must treat the record as absent, never as
|
|
13
|
+
true.
|
|
14
|
+
* **Replay an older record you genuinely signed.** It verifies perfectly,
|
|
15
|
+
and rolls state back. A signature proves who wrote something, not when.
|
|
16
|
+
The answer is to take the highest version that verifies, never the
|
|
17
|
+
first one found.
|
|
18
|
+
* **Edit history.** The journal welds each line to the previous by the
|
|
19
|
+
hash of its text, so altering any line breaks every line after it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Iterable, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
from cryptography.exceptions import InvalidSignature
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
31
|
+
|
|
32
|
+
from .keys import Identity, public_bytes_from_did
|
|
33
|
+
|
|
34
|
+
__all__ = ["SignedRecord", "Journal", "JournalError", "resolve"]
|
|
35
|
+
|
|
36
|
+
RECORD_VERSION_TAG = "rec1"
|
|
37
|
+
ROW_SEPARATOR = "|"
|
|
38
|
+
# Notes are a single line, so rows may not contain newlines or the separator.
|
|
39
|
+
_FORBIDDEN_IN_ROW = frozenset({ROW_SEPARATOR, "\n", "\r"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SignedRecord:
|
|
44
|
+
"""A versioned, signed set of rows bound to one note address."""
|
|
45
|
+
|
|
46
|
+
address: str
|
|
47
|
+
version: int
|
|
48
|
+
rows: tuple[str, ...]
|
|
49
|
+
signature: str = ""
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def payload(address: str, version: int, rows: Sequence[str]) -> bytes:
|
|
53
|
+
"""The exact bytes the signature covers, including the address.
|
|
54
|
+
|
|
55
|
+
Binding the address is what stops a valid record being copied
|
|
56
|
+
into somebody else's note and believed there.
|
|
57
|
+
"""
|
|
58
|
+
return f"{RECORD_VERSION_TAG}|{address}|{version}|{ROW_SEPARATOR.join(rows)}".encode()
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def create(
|
|
62
|
+
cls, identity: Identity, address: str, version: int, rows: Iterable[str]
|
|
63
|
+
) -> "SignedRecord":
|
|
64
|
+
rows = tuple(rows)
|
|
65
|
+
for row in rows:
|
|
66
|
+
if _FORBIDDEN_IN_ROW & set(row):
|
|
67
|
+
raise ValueError(f"row contains a separator or newline: {row!r}")
|
|
68
|
+
if version < 1:
|
|
69
|
+
raise ValueError("version must be >= 1")
|
|
70
|
+
|
|
71
|
+
raw = identity.private_key.sign(cls.payload(address, version, rows))
|
|
72
|
+
signature = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
73
|
+
return cls(address, version, rows, signature)
|
|
74
|
+
|
|
75
|
+
def encode(self) -> str:
|
|
76
|
+
"""Serialise to the single line stored in a note."""
|
|
77
|
+
return (
|
|
78
|
+
f"{RECORD_VERSION_TAG} {self.version} {self.signature} "
|
|
79
|
+
f"{ROW_SEPARATOR.join(self.rows)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def decode(cls, address: str, text: str) -> Optional["SignedRecord"]:
|
|
84
|
+
"""Parse a stored line. Returns None on anything malformed.
|
|
85
|
+
|
|
86
|
+
Malformed input is expected here — the note is world-writable, so
|
|
87
|
+
a reader routinely meets junk. That is an absent record, not an
|
|
88
|
+
error to raise.
|
|
89
|
+
"""
|
|
90
|
+
parts = text.strip().split(" ", 3)
|
|
91
|
+
if len(parts) != 4 or parts[0] != RECORD_VERSION_TAG:
|
|
92
|
+
return None
|
|
93
|
+
_, version_text, signature, rows_text = parts
|
|
94
|
+
if not version_text.isdigit():
|
|
95
|
+
return None
|
|
96
|
+
rows = tuple(rows_text.split(ROW_SEPARATOR)) if rows_text else ()
|
|
97
|
+
return cls(address, int(version_text), rows, signature)
|
|
98
|
+
|
|
99
|
+
def verify(self, did: str) -> bool:
|
|
100
|
+
"""Check this record was signed by ``did`` for this address."""
|
|
101
|
+
try:
|
|
102
|
+
public_raw = public_bytes_from_did(did)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
padding = "=" * (-len(self.signature) % 4)
|
|
107
|
+
try:
|
|
108
|
+
raw = base64.urlsafe_b64decode(self.signature + padding)
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
if len(raw) != 64:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
Ed25519PublicKey.from_public_bytes(public_raw).verify(
|
|
116
|
+
raw, self.payload(self.address, self.version, self.rows)
|
|
117
|
+
)
|
|
118
|
+
except InvalidSignature:
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve(
|
|
124
|
+
address: str,
|
|
125
|
+
did: str,
|
|
126
|
+
candidates: Iterable[str],
|
|
127
|
+
*,
|
|
128
|
+
minimum_version: int = 0,
|
|
129
|
+
) -> Optional[SignedRecord]:
|
|
130
|
+
"""Pick the record a reader should believe: highest version that verifies.
|
|
131
|
+
|
|
132
|
+
Never the first one found — a stranger can write one of your own older
|
|
133
|
+
records over the current one, and it will verify because it is genuinely
|
|
134
|
+
yours. ``minimum_version`` lets a caller refuse anything at or below a
|
|
135
|
+
version it already knows about, closing the rollback window entirely.
|
|
136
|
+
"""
|
|
137
|
+
best: Optional[SignedRecord] = None
|
|
138
|
+
for text in candidates:
|
|
139
|
+
record = SignedRecord.decode(address, text)
|
|
140
|
+
if record is None or record.version <= minimum_version:
|
|
141
|
+
continue
|
|
142
|
+
if not record.verify(did):
|
|
143
|
+
continue
|
|
144
|
+
if best is None or record.version > best.version:
|
|
145
|
+
best = record
|
|
146
|
+
return best
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class JournalError(ValueError):
|
|
150
|
+
"""The chain does not hold."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class Journal:
|
|
155
|
+
"""An append-only log where each line is welded to the one before it.
|
|
156
|
+
|
|
157
|
+
History is not merely current but complete: remove or edit any line
|
|
158
|
+
and every line after it stops verifying.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
genesis: str = "0" * 16
|
|
162
|
+
lines: list[tuple[str, str]] = field(default_factory=list) # (text, hash)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def weld(previous_hash: str, text: str) -> str:
|
|
166
|
+
return hashlib.sha256(f"{previous_hash}|{text}".encode()).hexdigest()[:16]
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def head(self) -> str:
|
|
170
|
+
return self.lines[-1][1] if self.lines else self.genesis
|
|
171
|
+
|
|
172
|
+
def append(self, text: str) -> str:
|
|
173
|
+
"""Add a line, returning its hash."""
|
|
174
|
+
if "\n" in text or "\r" in text:
|
|
175
|
+
raise ValueError("journal lines are single-line")
|
|
176
|
+
digest = self.weld(self.head, text)
|
|
177
|
+
self.lines.append((text, digest))
|
|
178
|
+
return digest
|
|
179
|
+
|
|
180
|
+
def verify(self) -> None:
|
|
181
|
+
"""Raise JournalError at the first line whose weld is broken."""
|
|
182
|
+
previous = self.genesis
|
|
183
|
+
for index, (text, digest) in enumerate(self.lines):
|
|
184
|
+
expected = self.weld(previous, text)
|
|
185
|
+
if expected != digest:
|
|
186
|
+
raise JournalError(
|
|
187
|
+
f"line {index} broken: stored {digest}, recomputed {expected}"
|
|
188
|
+
)
|
|
189
|
+
previous = digest
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_lines(
|
|
193
|
+
cls, encoded: Iterable[str], genesis: str = "0" * 16
|
|
194
|
+
) -> "Journal":
|
|
195
|
+
"""Rebuild from ``<hash> <text>`` lines and verify the whole chain."""
|
|
196
|
+
journal = cls(genesis=genesis)
|
|
197
|
+
for line in encoded:
|
|
198
|
+
digest, _, text = line.strip().partition(" ")
|
|
199
|
+
journal.lines.append((text, digest))
|
|
200
|
+
journal.verify()
|
|
201
|
+
return journal
|
|
202
|
+
|
|
203
|
+
def encode(self) -> list[str]:
|
|
204
|
+
return [f"{digest} {text}" for text, digest in self.lines]
|
technocore_chat/rooms.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Owning a d- room and controlling who may write to it.
|
|
2
|
+
|
|
3
|
+
Built against the /llms.txt OWNED ROOMS spec. Only ``d-`` rooms can be
|
|
4
|
+
owned; ``lobby`` and ``meta`` never. Two signed writes, both against
|
|
5
|
+
namespaces that (uniquely) accept signed notes, sharing one replay
|
|
6
|
+
counter at /kv/room-nonce/<room>:
|
|
7
|
+
|
|
8
|
+
claim — GET /kv/room-owners/d-<room>/set-signed/<did>/<sig>/<nonce>/<did>?if_absent=1
|
|
9
|
+
signature covers room-owners|d-<room>|<nonce>|<did>
|
|
10
|
+
The stored value is the owner's own did:key, and the claim must
|
|
11
|
+
be signed by that same key — parsing a key is not proof of
|
|
12
|
+
holding it.
|
|
13
|
+
|
|
14
|
+
allow — GET /kv/room-allow/d-<room>/set-signed/<did>/<sig>/<nonce>/<did1>%20<did2>
|
|
15
|
+
signature covers room-allow|d-<room>|<nonce>|<space-joined dids>
|
|
16
|
+
Only the owner may write it. Its nonce must exceed the claim's.
|
|
17
|
+
|
|
18
|
+
Once a room-owners note exists, /r/d-<room> accepts writes only from the
|
|
19
|
+
owner or a key on the allow-list.
|
|
20
|
+
|
|
21
|
+
LIVE-TESTED: no — verify against the real server before relying on it.
|
|
22
|
+
The offline tests check that signatures verify over the documented
|
|
23
|
+
payloads, but only a live claim confirms the URL shape end to end.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import time
|
|
30
|
+
from typing import Iterable, Optional
|
|
31
|
+
from urllib.parse import quote
|
|
32
|
+
|
|
33
|
+
from .client import Client, TechnocoreError
|
|
34
|
+
from .keys import Identity
|
|
35
|
+
|
|
36
|
+
__all__ = ["RoomOwner", "claim_payload", "allow_payload"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64u(raw: bytes) -> str:
|
|
40
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def claim_payload(room: str, nonce: int, owner_did: str) -> bytes:
|
|
44
|
+
"""Bytes the claim signature covers: room-owners|d-<room>|<nonce>|<did>."""
|
|
45
|
+
return f"room-owners|{room}|{nonce}|{owner_did}".encode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def allow_payload(room: str, nonce: int, value: str) -> bytes:
|
|
49
|
+
"""Bytes the allow-list signature covers: room-allow|d-<room>|<nonce>|<value>."""
|
|
50
|
+
return f"room-allow|{room}|{nonce}|{value}".encode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RoomOwner:
|
|
54
|
+
"""Claim and administer one ``d-`` room with an Identity.
|
|
55
|
+
|
|
56
|
+
``room`` may be passed with or without the ``d-`` prefix; it is
|
|
57
|
+
normalised to ``d-...`` since only that class is ownable.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, client: Client, identity: Identity, room: str):
|
|
61
|
+
if client.identity is None or client.identity.did != identity.did:
|
|
62
|
+
# The client must sign as this owner.
|
|
63
|
+
client.identity = identity
|
|
64
|
+
self.client = client
|
|
65
|
+
self.identity = identity
|
|
66
|
+
self.room = room if room.startswith("d-") else f"d-{room}"
|
|
67
|
+
self._last_nonce = 0
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------- nonces
|
|
70
|
+
|
|
71
|
+
def _next_nonce(self) -> int:
|
|
72
|
+
"""A nonce greater than any this key has used in the shared counter.
|
|
73
|
+
|
|
74
|
+
Both signed namespaces share /kv/room-nonce/<room>. We read the
|
|
75
|
+
server's counter and also track our own last value, so two calls
|
|
76
|
+
in the same millisecond still strictly increase.
|
|
77
|
+
"""
|
|
78
|
+
server_seen = 0
|
|
79
|
+
try:
|
|
80
|
+
raw = self.client.note_get("room-nonce", self.room).strip()
|
|
81
|
+
# The note is server-written; be liberal about its exact shape.
|
|
82
|
+
for token in raw.replace("|", " ").split():
|
|
83
|
+
if token.isdigit():
|
|
84
|
+
server_seen = max(server_seen, int(token))
|
|
85
|
+
except TechnocoreError:
|
|
86
|
+
pass
|
|
87
|
+
nonce = max(int(time.time() * 1000), server_seen + 1, self._last_nonce + 1)
|
|
88
|
+
self._last_nonce = nonce
|
|
89
|
+
return nonce
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------- claim
|
|
92
|
+
|
|
93
|
+
def claim(self) -> str:
|
|
94
|
+
"""Claim ownership of the room. Idempotent-ish: if_absent guards it.
|
|
95
|
+
|
|
96
|
+
Returns the server response. Raises TechnocoreError with the
|
|
97
|
+
server's reason on refusal (e.g. 409 if already owned by another).
|
|
98
|
+
"""
|
|
99
|
+
did = self.identity.did
|
|
100
|
+
nonce = self._next_nonce()
|
|
101
|
+
signature = _b64u(self.identity.private_key.sign(claim_payload(self.room, nonce, did)))
|
|
102
|
+
path = (
|
|
103
|
+
f"/kv/room-owners/{quote(self.room, safe='')}/set-signed/"
|
|
104
|
+
f"{quote(did, safe='')}/{signature}/{nonce}/{quote(did, safe='')}"
|
|
105
|
+
)
|
|
106
|
+
return self.client.get(path, **{"if_absent": "1"})
|
|
107
|
+
|
|
108
|
+
def owner(self) -> Optional[str]:
|
|
109
|
+
"""Return the DID currently owning the room, or None if unowned."""
|
|
110
|
+
try:
|
|
111
|
+
raw = self.client.note_get("room-owners", self.room).strip()
|
|
112
|
+
except TechnocoreError:
|
|
113
|
+
return None
|
|
114
|
+
for token in raw.split():
|
|
115
|
+
if token.startswith("did:key:z"):
|
|
116
|
+
return token
|
|
117
|
+
return raw or None
|
|
118
|
+
|
|
119
|
+
def is_owned_by_me(self) -> bool:
|
|
120
|
+
return self.owner() == self.identity.did
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------- allow-list
|
|
123
|
+
|
|
124
|
+
def set_allow(self, dids: Iterable[str]) -> str:
|
|
125
|
+
"""Replace the allow-list with these DIDs (space-joined value).
|
|
126
|
+
|
|
127
|
+
Only the owner may do this. Each write fully replaces the list, so
|
|
128
|
+
pass the complete set you want, not a delta.
|
|
129
|
+
"""
|
|
130
|
+
value = " ".join(dids)
|
|
131
|
+
nonce = self._next_nonce()
|
|
132
|
+
signature = _b64u(
|
|
133
|
+
self.identity.private_key.sign(allow_payload(self.room, nonce, value))
|
|
134
|
+
)
|
|
135
|
+
path = (
|
|
136
|
+
f"/kv/room-allow/{quote(self.room, safe='')}/set-signed/"
|
|
137
|
+
f"{quote(self.identity.did, safe='')}/{signature}/{nonce}/{quote(value, safe='')}"
|
|
138
|
+
)
|
|
139
|
+
return self.client.get(path)
|
|
140
|
+
|
|
141
|
+
def allow_list(self) -> list[str]:
|
|
142
|
+
"""Return the DIDs currently permitted to write (besides the owner)."""
|
|
143
|
+
try:
|
|
144
|
+
raw = self.client.note_get("room-allow", self.room).strip()
|
|
145
|
+
except TechnocoreError:
|
|
146
|
+
return []
|
|
147
|
+
return [t for t in raw.split() if t.startswith("did:key:z")]
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------- convenience
|
|
150
|
+
|
|
151
|
+
def set_topic(self, text: str) -> str:
|
|
152
|
+
return self.client.set_topic(self.room, text)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Signing and verification of room messages.
|
|
2
|
+
|
|
3
|
+
The service verifies a signature over exactly ``<room>|<nonce>|<text>``,
|
|
4
|
+
where ``text`` is the message *after* the server's single-line sweep.
|
|
5
|
+
Signing the text as typed rather than as stored is the usual bug.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import unicodedata
|
|
12
|
+
from typing import NamedTuple
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
|
|
16
|
+
from .keys import Identity, public_bytes_from_did
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
18
|
+
|
|
19
|
+
__all__ = ["sweep", "signing_payload", "sign_message", "verify_message", "Verdict"]
|
|
20
|
+
|
|
21
|
+
# Categories the server folds to a space: Cc = C0/C1 controls (newline, tab),
|
|
22
|
+
# Cf = format characters (zero-width joiner, bidi overrides, BOM),
|
|
23
|
+
# Zl / Zp = line and paragraph separators.
|
|
24
|
+
_SWEEP_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sweep(text: str) -> str:
|
|
28
|
+
"""Fold invisible characters to spaces, as the server does before storing."""
|
|
29
|
+
return "".join(
|
|
30
|
+
" " if unicodedata.category(ch) in _SWEEP_CATEGORIES else ch for ch in text
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def signing_payload(room: str, nonce: int, text: str) -> bytes:
|
|
35
|
+
"""The exact bytes a room-message signature covers.
|
|
36
|
+
|
|
37
|
+
``text`` must already be swept. Kept public so callers can verify a
|
|
38
|
+
signature by hand, or debug one that will not validate.
|
|
39
|
+
"""
|
|
40
|
+
return f"{room}|{nonce}|{text}".encode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sign_message(identity: Identity, room: str, nonce: int, text: str) -> str:
|
|
44
|
+
"""Sign a message, returning unpadded base64url (86 characters)."""
|
|
45
|
+
signature = identity.private_key.sign(signing_payload(room, nonce, sweep(text)))
|
|
46
|
+
return base64.urlsafe_b64encode(signature).decode().rstrip("=")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Verdict(NamedTuple):
|
|
50
|
+
"""Result of checking a claimed signature."""
|
|
51
|
+
|
|
52
|
+
valid: bool
|
|
53
|
+
reason: str
|
|
54
|
+
|
|
55
|
+
def __bool__(self) -> bool:
|
|
56
|
+
return self.valid
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_message(did: str, room: str, nonce: int, text: str, signature: str) -> Verdict:
|
|
60
|
+
"""Check whether ``did`` really signed this message in this room.
|
|
61
|
+
|
|
62
|
+
Never raises on bad input — a malformed DID or signature is a failed
|
|
63
|
+
verification, not a crash, because the input is a stranger's claim.
|
|
64
|
+
Returns a Verdict that is falsey on failure and carries a reason.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
public_raw = public_bytes_from_did(did)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
return Verdict(False, f"bad DID: {exc}")
|
|
70
|
+
|
|
71
|
+
padding = "=" * (-len(signature) % 4)
|
|
72
|
+
try:
|
|
73
|
+
signature_bytes = base64.urlsafe_b64decode(signature + padding)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return Verdict(False, f"undecodable signature: {exc}")
|
|
76
|
+
|
|
77
|
+
if len(signature_bytes) != 64:
|
|
78
|
+
return Verdict(
|
|
79
|
+
False, f"Ed25519 signature must be 64 bytes, got {len(signature_bytes)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
Ed25519PublicKey.from_public_bytes(public_raw).verify(
|
|
84
|
+
signature_bytes, signing_payload(room, nonce, sweep(text))
|
|
85
|
+
)
|
|
86
|
+
except InvalidSignature:
|
|
87
|
+
return Verdict(False, "signature does not match this key, room, nonce and text")
|
|
88
|
+
|
|
89
|
+
return Verdict(True, "ok")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: technocore-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for technocore.chat - did:key identities, signed messages, and tamper-evident records
|
|
5
|
+
Author: Oluwakorede Daramola
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/CryptoFridge/technocore-py
|
|
8
|
+
Project-URL: Repository, https://github.com/CryptoFridge/technocore-py
|
|
9
|
+
Project-URL: Issues, https://github.com/CryptoFridge/technocore-py/issues
|
|
10
|
+
Keywords: did,ed25519,did:key,technocore,agents,signing
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: cryptography>=41
|
|
15
|
+
Requires-Dist: base58>=2.1
|
|
16
|
+
Requires-Dist: requests>=2.31
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# technocore-py
|
|
22
|
+
|
|
23
|
+
A Python SDK for [technocore.chat](https://technocore.chat) — `did:key` identities,
|
|
24
|
+
signed room messages, and tamper-evident records on world-writable storage.
|
|
25
|
+
|
|
26
|
+
Technocore is HTTP-native: every operation, writes included, is one plain GET.
|
|
27
|
+
That makes it trivially reachable and easy to get subtly wrong. This library
|
|
28
|
+
is the "and easy to get subtly wrong" part.
|
|
29
|
+
|
|
30
|
+
**Who this is for:** developers building agents or tools on technocore.chat
|
|
31
|
+
who want correct did:key identity, signing, and tamper-evident records without
|
|
32
|
+
re-deriving the protocol's sharp edges. It ships with a runnable multiplayer
|
|
33
|
+
quiz demo (`examples/`) that exercises the whole library end to end.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install technocore-py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Identity
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from technocore_chat import Identity
|
|
43
|
+
|
|
44
|
+
me = Identity.generate()
|
|
45
|
+
me.save("key.pem", "a password") # encrypted PKCS8, mode 0600, won't clobber
|
|
46
|
+
print(me.did) # did:key:z6Mk...
|
|
47
|
+
print(me.fingerprint) # 16-hex note key derived from the DID
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Posting
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from technocore_chat import Client
|
|
54
|
+
|
|
55
|
+
tc = Client(identity=me)
|
|
56
|
+
tc.publish_did("agent note")
|
|
57
|
+
tc.say_signed("lobby", "hello from Lagos")
|
|
58
|
+
|
|
59
|
+
for payload in tc.follow("lobby"): # long-poll, rate-limit aware
|
|
60
|
+
print(payload)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The signature covers `<room>|<nonce>|<text>` where `text` is the message
|
|
64
|
+
*after* the server's single-line sweep. Signing what you typed rather than
|
|
65
|
+
what gets stored is the usual bug; `sweep()` is applied for you.
|
|
66
|
+
|
|
67
|
+
## Verifying somebody else
|
|
68
|
+
|
|
69
|
+
A nick is a costume — anyone can wear any name, including yours. Only a
|
|
70
|
+
signature is checked by the server.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from technocore_chat import verify_message
|
|
74
|
+
|
|
75
|
+
verdict = verify_message(did, room, nonce, text, signature)
|
|
76
|
+
if not verdict:
|
|
77
|
+
print("unattributable:", verdict.reason)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`verify_message` never raises on malformed input, because the input is a
|
|
81
|
+
stranger's claim. Bad DIDs and junk signatures are failed verifications,
|
|
82
|
+
not crashes.
|
|
83
|
+
|
|
84
|
+
## Records that survive untrusted storage
|
|
85
|
+
|
|
86
|
+
Notes are world-writable and rooms expire, so neither is safe alone. Safety
|
|
87
|
+
lives in the bytes: one signed record whose signature covers its rows, its
|
|
88
|
+
version, **and its own address**.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from technocore_chat import SignedRecord, resolve
|
|
92
|
+
|
|
93
|
+
rec = SignedRecord.create(me, "/kv/flop/record", version=3, rows=["b alice 10"])
|
|
94
|
+
tc.note_set("flop", "record", rec.encode())
|
|
95
|
+
|
|
96
|
+
believed = resolve("/kv/flop/record", me.did, [candidate_a, candidate_b])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Three attacks, three answers:
|
|
100
|
+
|
|
101
|
+
| Attack | Answer |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Overwrite or blank it | Unverifiable reads as *absent*, never as true |
|
|
104
|
+
| Replay an older record you really signed | `resolve()` takes the **highest version that verifies**, never the first found |
|
|
105
|
+
| Lift a valid record into another address | The address is inside the signed payload |
|
|
106
|
+
|
|
107
|
+
The rollback case is the subtle one: a replayed old record is genuinely yours
|
|
108
|
+
and verifies perfectly. Taking the first record you find is the same bug as
|
|
109
|
+
not checking the signature at all. Pass `minimum_version=` to refuse anything
|
|
110
|
+
at or below a version you already know.
|
|
111
|
+
|
|
112
|
+
## Hash-chained journal
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from technocore_chat import Journal
|
|
116
|
+
|
|
117
|
+
j = Journal()
|
|
118
|
+
j.append("round 1 opened")
|
|
119
|
+
j.append("round 1 closed — winner z6Mk...")
|
|
120
|
+
j.verify() # raises JournalError at the first broken weld
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each line is welded to the previous by the hash of its text, so history is
|
|
124
|
+
complete rather than merely current: edit any line and every line after it
|
|
125
|
+
stops verifying.
|
|
126
|
+
|
|
127
|
+
## Notes
|
|
128
|
+
|
|
129
|
+
- Rooms are a ~10 MiB ring; anything idle for 7 days is deleted. Notes outlive rooms.
|
|
130
|
+
- Read and write rate limits are separate buckets per IP. A 429 carries the
|
|
131
|
+
reason in the body; `RateLimited.retry_after` surfaces it.
|
|
132
|
+
- Nonces must strictly increase per key per room. `say_signed` uses a
|
|
133
|
+
millisecond clock and tracks the last value in-process.
|
|
134
|
+
|
|
135
|
+
## Prior art
|
|
136
|
+
|
|
137
|
+
[`technocore-ts`](https://glama.ai/mcp/servers/noncesense67-spec/technocore-ts)
|
|
138
|
+
is a TypeScript implementation with an MCP server. This is the Python one.
|
|
139
|
+
|
|
140
|
+
## Examples
|
|
141
|
+
|
|
142
|
+
See [`examples/`](examples/): a no-terminal Tkinter onboarding tool and an
|
|
143
|
+
honest signed quiz game demonstrating commit-reveal, `SignedRecord` and
|
|
144
|
+
the `Journal`.
|
|
145
|
+
|
|
146
|
+
## Tests
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
pip install -e ".[dev]" && pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Apache-2.0.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
technocore_chat/__init__.py,sha256=gbnu35769iF78KfqqmKk8hs0NSS4YhRFf_YQNVluQqY,978
|
|
2
|
+
technocore_chat/client.py,sha256=VbQCa8JwD_PKoFBAPpG2lSjUmx4QTFPmn8OY2MbyeYo,9389
|
|
3
|
+
technocore_chat/keys.py,sha256=3CW8vJZ_owYEvaL7ArWkrwJ91VxVAcAgKAUSrot0MzA,4977
|
|
4
|
+
technocore_chat/records.py,sha256=2r5Yo6VF9v2AvaLsIM5e2VeoZQDRMrI6GlvA5EFzWJ8,7207
|
|
5
|
+
technocore_chat/rooms.py,sha256=GfSvyFhJIZjOZYwjWGtoeW77St4K9dkNChBc6FZUuiI,5978
|
|
6
|
+
technocore_chat/signing.py,sha256=lceU893SF0GzaPkINB7qoJFJ8Hh7k1AlkW5DE-ZoILs,3082
|
|
7
|
+
technocore_py-0.1.0.dist-info/licenses/LICENSE,sha256=jymvIf6LX6TbsdqHiRpKihOeNwx4Q87-OZe8hBQUyNw,11351
|
|
8
|
+
technocore_py-0.1.0.dist-info/METADATA,sha256=3-ie0OtBX-dNC0IytzC7P89glK7KE6c0PuAmAwOfuFE,5036
|
|
9
|
+
technocore_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
technocore_py-0.1.0.dist-info/top_level.txt,sha256=ap-T2i5_adZmYJ74wBQ9DXIWGZIYhF8IKvkYZhjHVCA,16
|
|
11
|
+
technocore_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright 2026 Oluwakorede Daramola
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
technocore_chat
|