aurival 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.
aurival/__init__.py ADDED
@@ -0,0 +1,134 @@
1
+ """Aurival bot SDK.
2
+
3
+ Declare commands, call run(), and the SDK holds the socket::
4
+
5
+ from aurival import Bot
6
+
7
+ bot = Bot()
8
+
9
+ @bot.command("ping")
10
+ async def ping(ctx):
11
+ await ctx.reply("pong")
12
+
13
+ bot.run()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .bot import Bot
19
+ from .errors import (
20
+ CODE_CLASSES,
21
+ DOC_URL_PREFIX,
22
+ TYPE_CLASSES,
23
+ AccessTokenExpired,
24
+ AccessTokenInvalid,
25
+ AckUnknownEvent,
26
+ APIError,
27
+ AssertionExpired,
28
+ AssertionReplay,
29
+ AurivalAPIError,
30
+ AurivalError,
31
+ AuthenticationError,
32
+ BadAssertion,
33
+ BadProof,
34
+ BadPublicKey,
35
+ BotLinkNotAllowed,
36
+ BotPlaygroundOnly,
37
+ BotSuspended,
38
+ EmptyText,
39
+ FrameInvalid,
40
+ FrameTooLarge,
41
+ IdempotencyKeyInvalid,
42
+ IdempotencyKeyReused,
43
+ IdleTimeout,
44
+ InternalError,
45
+ InvalidCommandName,
46
+ InvalidJSON,
47
+ InvalidRequestError,
48
+ KeyAlreadyPaired,
49
+ KeyRevoked,
50
+ NotFound,
51
+ PairRateLimited,
52
+ ParameterInvalid,
53
+ ParameterMissing,
54
+ PermissionDeniedError,
55
+ ProtocolError,
56
+ RateLimited,
57
+ RateLimitError,
58
+ ServerRestarting,
59
+ SessionSuperseded,
60
+ SyncRateLimited,
61
+ TextTooLong,
62
+ TooManyProblems,
63
+ TransportError,
64
+ UnknownOperation,
65
+ UnknownParameter,
66
+ from_envelope,
67
+ )
68
+ from .events import Chat, Command, Context, Event, User
69
+ from .socket import BYE_ACTIONS, ByeAction, action_for_bye
70
+
71
+ __version__ = "0.1.0"
72
+
73
+ # Everything a developer can name. The JS package mirrors this list file for file
74
+ # (SDK-7), so a name missing here becomes a permanent asymmetry: adding an export
75
+ # later is free, removing one is a break.
76
+ __all__ = [
77
+ "BYE_ACTIONS",
78
+ "CODE_CLASSES",
79
+ "DOC_URL_PREFIX",
80
+ "TYPE_CLASSES",
81
+ "APIError",
82
+ "AccessTokenExpired",
83
+ "AccessTokenInvalid",
84
+ "AckUnknownEvent",
85
+ "AssertionExpired",
86
+ "AssertionReplay",
87
+ "AurivalAPIError",
88
+ "AurivalError",
89
+ "AuthenticationError",
90
+ "BadAssertion",
91
+ "BadProof",
92
+ "BadPublicKey",
93
+ "Bot",
94
+ "BotLinkNotAllowed",
95
+ "BotPlaygroundOnly",
96
+ "BotSuspended",
97
+ "ByeAction",
98
+ "Chat",
99
+ "Command",
100
+ "Context",
101
+ "EmptyText",
102
+ "Event",
103
+ "FrameInvalid",
104
+ "FrameTooLarge",
105
+ "IdempotencyKeyInvalid",
106
+ "IdempotencyKeyReused",
107
+ "IdleTimeout",
108
+ "InternalError",
109
+ "InvalidCommandName",
110
+ "InvalidJSON",
111
+ "InvalidRequestError",
112
+ "KeyAlreadyPaired",
113
+ "KeyRevoked",
114
+ "NotFound",
115
+ "PairRateLimited",
116
+ "ParameterInvalid",
117
+ "ParameterMissing",
118
+ "PermissionDeniedError",
119
+ "ProtocolError",
120
+ "RateLimitError",
121
+ "RateLimited",
122
+ "ServerRestarting",
123
+ "SessionSuperseded",
124
+ "SyncRateLimited",
125
+ "TextTooLong",
126
+ "TooManyProblems",
127
+ "TransportError",
128
+ "UnknownOperation",
129
+ "UnknownParameter",
130
+ "User",
131
+ "__version__",
132
+ "action_for_bye",
133
+ "from_envelope",
134
+ ]
aurival/auth.py ADDED
@@ -0,0 +1,416 @@
1
+ """The key file, first-run pairing, assertion signing, access-token exchange.
2
+
3
+ SDK-33: the seed lives in exactly one object (`MachineKey`) and never leaves
4
+ it — not an attribute anywhere else, not a local in dispatch, not in a log
5
+ line or exception message. The only path the b64 seed travels is
6
+ `KeyFile.load` -> `MachineKey.from_seed_b64` (decoded immediately) and
7
+ `MachineKey._seed_for_save` -> `KeyFile.save` (encoded only at the moment of
8
+ writing).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import base64
15
+ import contextlib
16
+ import dataclasses
17
+ import hashlib
18
+ import ipaddress
19
+ import json
20
+ import logging
21
+ import os
22
+ import pathlib
23
+ import socket as _socket
24
+ import tempfile
25
+ import time
26
+ import urllib.parse
27
+ from collections.abc import Callable
28
+ from datetime import datetime, timezone
29
+ from typing import TYPE_CHECKING
30
+
31
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
32
+ Ed25519PrivateKey,
33
+ Ed25519PublicKey,
34
+ )
35
+ from cryptography.hazmat.primitives.serialization import (
36
+ Encoding,
37
+ NoEncryption,
38
+ PrivateFormat,
39
+ PublicFormat,
40
+ )
41
+
42
+ from . import errors
43
+ from .http import DEFAULT_HOST
44
+
45
+ if TYPE_CHECKING:
46
+ from .http import HttpClient
47
+
48
+ # Re-exported, not redefined: bot.py compares the resolved host against http's
49
+ # copy, and two spellings of one default drift silently.
50
+ __all__ = ["DEFAULT_HOST"]
51
+
52
+ # token.go: AssertionMaxAge. auth owns 60s of refresh headroom ahead of a
53
+ # 15-minute access-token TTL (SDK-37).
54
+ _ASSERTION_MAX_AGE = 120
55
+ _TOKEN_REFRESH_HEADROOM = 60.0
56
+ _DEFAULT_PAIR_RETRY_AFTER = 60.0
57
+
58
+ # botapi.go:158 — a custom base32 alphabet, no padding. Not base64.b32encode.
59
+ _B32_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
60
+
61
+
62
+ def _b32encode_nopad(data: bytes) -> str:
63
+ bits = 0
64
+ value = 0
65
+ out: list[str] = []
66
+ for byte in data:
67
+ value = (value << 8) | byte
68
+ bits += 8
69
+ while bits >= 5:
70
+ index = (value >> (bits - 5)) & 0x1F
71
+ out.append(_B32_ALPHABET[index])
72
+ bits -= 5
73
+ if bits > 0:
74
+ out.append(_B32_ALPHABET[(value << (5 - bits)) & 0x1F])
75
+ return "".join(out)
76
+
77
+
78
+ def _fingerprint_of(canonical_b64: str) -> str:
79
+ # botapi.go:206 — hashes the canonical base64 STRING, not the raw bytes.
80
+ digest = hashlib.sha256(canonical_b64.encode("ascii")).digest()
81
+ s = _b32encode_nopad(digest)[:8]
82
+ return (s[:4] + "-" + s[4:]).upper()
83
+
84
+
85
+ def _b64url_nopad(data: bytes) -> str:
86
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
87
+
88
+
89
+ def _parse_rfc3339(s: str) -> float:
90
+ # time.RFC3339 in Go always has a "Z" or numeric offset, never a bare
91
+ # local time; datetime.fromisoformat wants "+00:00", not "Z" (py3.10).
92
+ if s.endswith("Z"):
93
+ s = s[:-1] + "+00:00"
94
+ return datetime.fromisoformat(s).timestamp()
95
+
96
+
97
+ class MachineKey:
98
+ """The ONLY object that holds the seed (SDK-33)."""
99
+
100
+ __slots__ = ("_private_key",)
101
+
102
+ def __init__(self, private_key: Ed25519PrivateKey) -> None:
103
+ self._private_key = private_key
104
+
105
+ @classmethod
106
+ def generate(cls) -> MachineKey:
107
+ return cls(Ed25519PrivateKey.generate())
108
+
109
+ @classmethod
110
+ def from_seed_b64(cls, seed_b64: str) -> MachineKey:
111
+ seed = base64.b64decode(seed_b64)
112
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
113
+
114
+ def sign(self, payload: bytes) -> bytes:
115
+ return self._private_key.sign(payload)
116
+
117
+ @property
118
+ def public_key_b64(self) -> str:
119
+ pub: Ed25519PublicKey = self._private_key.public_key()
120
+ raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
121
+ return base64.b64encode(raw).decode("ascii")
122
+
123
+ @property
124
+ def fingerprint(self) -> str:
125
+ return _fingerprint_of(self.public_key_b64)
126
+
127
+ def _seed_b64_for_save(self) -> str:
128
+ """The seed's one legitimate exit. Called ONLY by `KeyFile.save`."""
129
+ raw = self._private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
130
+ return base64.b64encode(raw).decode("ascii")
131
+
132
+ def __repr__(self) -> str:
133
+ return f"<MachineKey fingerprint={self.fingerprint}>"
134
+
135
+ __str__ = __repr__
136
+
137
+
138
+ @dataclasses.dataclass(frozen=True)
139
+ class Machine:
140
+ """machine.json minus the seed. Safe to log."""
141
+
142
+ bot: str
143
+ machine: str
144
+ host: str
145
+ created: str
146
+
147
+
148
+ class KeyFile:
149
+ def __init__(self, path: pathlib.Path | None = None) -> None:
150
+ if path is not None:
151
+ self.path = path
152
+ self.is_default = False
153
+ else:
154
+ override = os.environ.get("AURIVAL_KEY_PATH")
155
+ if override:
156
+ self.path = pathlib.Path(override)
157
+ self.is_default = False
158
+ else:
159
+ self.path = self.default_path()
160
+ self.is_default = True
161
+
162
+ @staticmethod
163
+ def default_path() -> pathlib.Path:
164
+ return pathlib.Path(".aurival") / "machine.json"
165
+
166
+ def load(self) -> tuple[MachineKey, Machine] | None:
167
+ if not self.path.exists():
168
+ return None
169
+ with self.path.open("r", encoding="utf-8") as f:
170
+ doc = json.load(f)
171
+ key = MachineKey.from_seed_b64(doc["seed"])
172
+ machine = Machine(
173
+ bot=doc["bot"], machine=doc["machine"], host=doc["host"], created=doc["created"]
174
+ )
175
+ return key, machine
176
+
177
+ def save(self, key: MachineKey, machine: Machine) -> None:
178
+ directory = self.path.parent
179
+ directory.mkdir(parents=True, exist_ok=True)
180
+ os.chmod(directory, 0o700)
181
+
182
+ doc = {
183
+ "seed": key._seed_b64_for_save(),
184
+ "bot": machine.bot,
185
+ "machine": machine.machine,
186
+ "host": machine.host,
187
+ "created": machine.created,
188
+ }
189
+ fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=".machine-", suffix=".tmp")
190
+ try:
191
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
192
+ json.dump(doc, f)
193
+ os.chmod(tmp_name, 0o600)
194
+ os.replace(tmp_name, self.path)
195
+ except BaseException:
196
+ with contextlib.suppress(OSError):
197
+ os.unlink(tmp_name)
198
+ raise
199
+
200
+ if self.is_default:
201
+ gitignore = directory / ".gitignore"
202
+ gitignore.write_text("*\n", encoding="utf-8")
203
+
204
+
205
+ class Auth:
206
+ def __init__(
207
+ self,
208
+ http: HttpClient,
209
+ key: MachineKey,
210
+ machine: Machine,
211
+ logger: logging.Logger | None = None,
212
+ ) -> None:
213
+ self._http = http
214
+ self._key = key
215
+ self._machine_id = machine.machine
216
+ self.bot = machine.bot
217
+ self._logger = logger or logging.getLogger("aurival")
218
+ self._token: str | None = None
219
+ self._expires_at: float | None = None
220
+ self._lock = asyncio.Lock()
221
+
222
+ def _fresh(self) -> bool:
223
+ return (
224
+ self._token is not None
225
+ and self._expires_at is not None
226
+ and time.time() < self._expires_at - _TOKEN_REFRESH_HEADROOM
227
+ )
228
+
229
+ async def token(self) -> str:
230
+ """The current access token, minting one only if we have no fresh one.
231
+
232
+ THE RE-CHECK INSIDE THE LOCK IS THE POINT. Handlers run concurrently and
233
+ every one of them reaches this through `ctx.reply`, so N callers arrive
234
+ together the moment a token goes stale. Without it each waiter takes the
235
+ lock in turn and performs its own exchange: N assertions, N `/v1/token`
236
+ round trips, and N-1 tokens thrown away — a self-inflicted burst against
237
+ the endpoint whose rate limit would take the bot offline.
238
+ """
239
+ if self._fresh():
240
+ assert self._token is not None
241
+ return self._token
242
+ async with self._lock:
243
+ # The first waiter minted one while we queued. Reuse it.
244
+ if self._fresh():
245
+ assert self._token is not None
246
+ return self._token
247
+ return await self._exchange()
248
+
249
+ async def refresh(self) -> str:
250
+ """Force a new exchange, whatever we currently hold.
251
+
252
+ DELIBERATELY NOT FRESHNESS-CHECKED, and it is not the same function as
253
+ `token()`. Its one caller is the HTTP 401 path (SDK-37): the server has
254
+ just REJECTED the token we hold, so "it still looks fresh by our clock"
255
+ is precisely the wrong answer — clock skew or a server-side rotation
256
+ both produce it, and returning the rejected token would burn the single
257
+ permitted retry and raise on a request that a real refresh would have
258
+ completed.
259
+ """
260
+ async with self._lock:
261
+ return await self._exchange()
262
+
263
+ async def _exchange(self) -> str:
264
+ """One assertion, one `/v1/token`. Callers hold the lock."""
265
+ assertion = self.build_assertion()
266
+ resp = await self._http.request(
267
+ "POST",
268
+ "/v1/token",
269
+ body={"assertion": assertion},
270
+ authenticated=False,
271
+ )
272
+ token = resp["access_token"]
273
+ if not isinstance(token, str):
274
+ raise errors.ProtocolError("token response missing access_token")
275
+ self._token = token
276
+ self._expires_at = _parse_rfc3339(str(resp["expires_at"]))
277
+ self._logger.debug("access token refreshed")
278
+ return self._token
279
+
280
+ def build_assertion(self, *, now: float | None = None) -> str:
281
+ iat = int(now if now is not None else time.time())
282
+ exp = iat + _ASSERTION_MAX_AGE
283
+ nonce = _b64url_nopad(os.urandom(16))
284
+ payload = {
285
+ "key_id": self._machine_id,
286
+ "bot_id": self.bot,
287
+ "iat": iat,
288
+ "exp": exp,
289
+ "nonce": nonce,
290
+ }
291
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
292
+ sig = self._key.sign(raw)
293
+ return f"{_b64url_nopad(raw)}.{_b64url_nopad(sig)}"
294
+
295
+
296
+ def machine_label() -> str:
297
+ # pairing.go:64 falls back on the server side past 64 chars anyway; we
298
+ # truncate up front so the sent label and the signed label always match.
299
+ return _socket.gethostname()[:64]
300
+
301
+
302
+ # Captured here so `pair`'s `machine_label` parameter can shadow the name
303
+ # without losing the default implementation.
304
+ _default_machine_label = machine_label
305
+
306
+
307
+ def _is_loopback_hostname(hostname: str) -> bool:
308
+ if hostname == "localhost":
309
+ return True
310
+ try:
311
+ return ipaddress.ip_address(hostname).is_loopback
312
+ except ValueError:
313
+ return False
314
+
315
+
316
+ def resolve_host() -> str:
317
+ host = os.environ.get("AURIVAL_API")
318
+ if not host:
319
+ return DEFAULT_HOST
320
+ parsed = urllib.parse.urlsplit(host)
321
+ hostname = parsed.hostname or ""
322
+ if parsed.scheme != "https" and not _is_loopback_hostname(hostname):
323
+ # An SDK exception, never a bare ValueError. The README promises one
324
+ # `except AurivalError` catches everything this library raises, and a
325
+ # builtin leaking out of the very first call in `run()` breaks that
326
+ # promise at the least convenient moment.
327
+ raise errors.AurivalError(
328
+ f"AURIVAL_API must be https:// unless the host is loopback, got {host!r}"
329
+ )
330
+ return host
331
+
332
+
333
+ async def pair(
334
+ http: HttpClient,
335
+ *,
336
+ machine_label: str | None = None,
337
+ host: str,
338
+ out: Callable[[str], None] = print,
339
+ logger: logging.Logger | None = None,
340
+ ) -> tuple[MachineKey, Machine]:
341
+ log = logger or logging.getLogger("aurival")
342
+ label = machine_label if machine_label is not None else _default_machine_label()
343
+ key = MachineKey.generate()
344
+
345
+ while True:
346
+ iat = int(time.time())
347
+ proof = key.sign(f"pair-start:{label}:{iat}".encode())
348
+ try:
349
+ start = await http.request(
350
+ "POST",
351
+ "/v1/pair/start",
352
+ authenticated=False,
353
+ body={
354
+ "machine_label": label,
355
+ "public_key": key.public_key_b64,
356
+ "proof": base64.b64encode(proof).decode("ascii"),
357
+ "iat": iat,
358
+ },
359
+ )
360
+ except errors.PairRateLimited as exc:
361
+ wait = exc.retry_after if exc.retry_after is not None else _DEFAULT_PAIR_RETRY_AFTER
362
+ log.info("pairing rate limited, waiting %.0fs", wait)
363
+ await asyncio.sleep(wait)
364
+ continue
365
+
366
+ user_code = str(start["user_code"])
367
+ fingerprint = str(start["fingerprint"])
368
+ poll_token = str(start["poll_token"])
369
+ interval_s = float(start["interval_ms"]) / 1000.0
370
+
371
+ # Directly to stdout, never the logger (SDK-31) — a dev with the
372
+ # logger at WARNING must still see this. The lead's e2e matches these
373
+ # two lines verbatim: `r"pairing code\s+([0-9A-Z]{4}-[0-9A-Z]{4})"`.
374
+ out(f"aurival: pairing code {user_code}")
375
+ out(f"aurival: fingerprint {fingerprint}")
376
+ out("Compare the fingerprint in the app, then approve. Waiting...")
377
+
378
+ machine, expired = await _poll_until_settled(http, poll_token, interval_s, host)
379
+ if machine is not None:
380
+ return key, machine
381
+ assert expired
382
+ log.info("pairing code expired, starting a new one")
383
+ out("That code expired. Here is a new one:")
384
+ # SDK-36: unbounded restart, one new code per restart. Loop again.
385
+
386
+
387
+ async def _poll_until_settled(
388
+ http: HttpClient, poll_token: str, interval_s: float, host: str
389
+ ) -> tuple[Machine | None, bool]:
390
+ """Returns (machine, expired). Exactly one of the two paths is non-empty:
391
+ a `Machine` on approval, or `expired=True` telling `pair` to restart.
392
+ """
393
+ headers = {"Authorization": f"Bearer {poll_token}"}
394
+ while True:
395
+ await asyncio.sleep(interval_s)
396
+ try:
397
+ resp = await http.request("POST", "/v1/pair/poll", authenticated=False, headers=headers)
398
+ except errors.PairRateLimited as exc:
399
+ wait = exc.retry_after if exc.retry_after is not None else _DEFAULT_PAIR_RETRY_AFTER
400
+ await asyncio.sleep(wait)
401
+ continue
402
+
403
+ state = resp["state"]
404
+ if state == "pending":
405
+ continue
406
+ if state == "expired":
407
+ return None, True
408
+ if state == "approved":
409
+ machine = Machine(
410
+ bot=str(resp["bot"]),
411
+ machine=str(resp["machine"]),
412
+ host=host,
413
+ created=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
414
+ )
415
+ return machine, False
416
+ raise errors.ProtocolError(f"unknown pairing state: {state!r}")