adaraz 0.1.1__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.
adaraz/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """ADARAZ Link: secure connection between ADARAZ devices and servers.
2
+
3
+ See ``docs/protocol.md`` in the repository for the wire protocol.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .codes import derive_psk, format_code, generate_code, normalize_code
9
+ from .const import PROTOCOL_VERSION, RELAY_COMMANDS, SUBPROTOCOL
10
+ from .exceptions import (
11
+ AdarazError,
12
+ ConnectionClosed,
13
+ DeviceNotConnected,
14
+ DeviceNotFound,
15
+ InvalidCodeError,
16
+ PairingCancelled,
17
+ PairingError,
18
+ PairingExpired,
19
+ PairingFailed,
20
+ PairingRejected,
21
+ ProtocolError,
22
+ RelayTimeout,
23
+ )
24
+ from .messages import Cursor, Hello, RelayResult, State
25
+ from .noise import generate_private_key, public_key_from_private
26
+ from .server import (
27
+ DeviceConnected,
28
+ DeviceDisconnected,
29
+ DevicePaired,
30
+ DeviceSession,
31
+ DeviceUnpaired,
32
+ LinkEvent,
33
+ LinkServer,
34
+ PairingWindow,
35
+ PunchReceived,
36
+ StateChanged,
37
+ server_id,
38
+ )
39
+ from .storage import (
40
+ InMemoryStorage,
41
+ LinkStorage,
42
+ PairedDevice,
43
+ PunchDeduplicator,
44
+ PunchKey,
45
+ PunchRecord,
46
+ )
47
+ from .transport import MemoryTransport, Transport, build_ws_url, memory_pipe
48
+
49
+ __version__ = "0.1.1"
50
+
51
+ __all__ = [
52
+ "PROTOCOL_VERSION",
53
+ "RELAY_COMMANDS",
54
+ "SUBPROTOCOL",
55
+ "AdarazError",
56
+ "ConnectionClosed",
57
+ "Cursor",
58
+ "DeviceConnected",
59
+ "DeviceDisconnected",
60
+ "DeviceNotConnected",
61
+ "DeviceNotFound",
62
+ "DevicePaired",
63
+ "DeviceSession",
64
+ "DeviceUnpaired",
65
+ "Hello",
66
+ "InMemoryStorage",
67
+ "InvalidCodeError",
68
+ "LinkEvent",
69
+ "LinkServer",
70
+ "LinkStorage",
71
+ "MemoryTransport",
72
+ "PairedDevice",
73
+ "PairingCancelled",
74
+ "PairingError",
75
+ "PairingExpired",
76
+ "PairingFailed",
77
+ "PairingRejected",
78
+ "PairingWindow",
79
+ "ProtocolError",
80
+ "PunchDeduplicator",
81
+ "PunchKey",
82
+ "PunchReceived",
83
+ "PunchRecord",
84
+ "RelayResult",
85
+ "RelayTimeout",
86
+ "State",
87
+ "StateChanged",
88
+ "Transport",
89
+ "__version__",
90
+ "build_ws_url",
91
+ "derive_psk",
92
+ "format_code",
93
+ "generate_code",
94
+ "generate_private_key",
95
+ "memory_pipe",
96
+ "normalize_code",
97
+ "public_key_from_private",
98
+ "server_id",
99
+ ]
adaraz/_channel.py ADDED
@@ -0,0 +1,130 @@
1
+ """Encrypted message channel shared by the server and the device implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import itertools
8
+ import logging
9
+
10
+ from .const import CLOSE_GOING_AWAY, MAX_NOISE_MESSAGE
11
+ from .exceptions import ConnectionClosed, ProtocolError
12
+ from .messages import Message, Ping, Pong, UnknownMessage, decode, encode
13
+ from .noise import CipherState, NoiseError
14
+ from .transport import Transport
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ class SecureChannel:
20
+ """Noise transport messages over a :class:`Transport`.
21
+
22
+ * serialises sends (the Noise nonce must match the order on the wire),
23
+ * answers ``ping`` with ``pong`` and swallows ``pong`` and unknown types,
24
+ * rejects known types that are not valid in this direction (close 4000),
25
+ * implements liveness (:meth:`run_liveness`).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ transport: Transport,
31
+ send_cipher: CipherState,
32
+ recv_cipher: CipherState,
33
+ *,
34
+ accept: frozenset[type[Message]],
35
+ ping_interval: float,
36
+ idle_timeout: float,
37
+ ) -> None:
38
+ self.transport = transport
39
+ self._send_cipher = send_cipher
40
+ self._recv_cipher = recv_cipher
41
+ self._accept = accept
42
+ self._ping_interval = ping_interval
43
+ self._idle_timeout = idle_timeout
44
+ self._lock = asyncio.Lock()
45
+ loop = asyncio.get_running_loop()
46
+ self._time = loop.time
47
+ self._last_send = self._last_recv = self._time()
48
+ self._ping_ids = itertools.count(1)
49
+ self.close_code: int | None = None
50
+
51
+ async def send(self, message: Message) -> None:
52
+ payload = encode(message)
53
+ async with self._lock:
54
+ if self.close_code is not None:
55
+ raise ConnectionClosed("channel closed")
56
+ data = self._send_cipher.encrypt_with_ad(b"", payload)
57
+ self._last_send = self._time()
58
+ try:
59
+ await self.transport.send(data)
60
+ except ConnectionClosed:
61
+ raise
62
+ except Exception as err:
63
+ raise ConnectionClosed(str(err)) from err
64
+
65
+ async def recv(self) -> Message | None:
66
+ """Return the next application message, or ``None`` when the connection ends."""
67
+ while True:
68
+ message = await self.recv_any()
69
+ if message is None:
70
+ return None
71
+ if await self.filter(message):
72
+ return message
73
+
74
+ async def recv_any(self) -> Message | None:
75
+ """Receive, decrypt and decode the next transport message, whatever its type.
76
+
77
+ Returns ``None`` when the connection ends. Nothing is answered or filtered;
78
+ pass the message to :meth:`filter` before using it.
79
+ """
80
+ data = await self.transport.recv()
81
+ if data is None:
82
+ return None
83
+ self._last_recv = self._time()
84
+ if len(data) > MAX_NOISE_MESSAGE:
85
+ raise ProtocolError("message too large")
86
+ try:
87
+ payload = self._recv_cipher.decrypt_with_ad(b"", data)
88
+ except NoiseError as err:
89
+ raise ProtocolError(f"cannot decrypt transport message: {err}") from err
90
+ return decode(payload)
91
+
92
+ async def filter(self, message: Message) -> bool:
93
+ """Handle the channel level part of a message; True if it is for the application.
94
+
95
+ ``ping`` is answered with ``pong``; ``pong`` and unknown types are swallowed;
96
+ a known type not valid in this direction raises :class:`ProtocolError`.
97
+ """
98
+ if isinstance(message, Ping):
99
+ # If the connection is gone, the next recv returns None.
100
+ with contextlib.suppress(ConnectionClosed):
101
+ await self.send(Pong(id=message.id))
102
+ return False
103
+ if isinstance(message, (Pong, UnknownMessage)):
104
+ return False
105
+ if type(message) not in self._accept:
106
+ raise ProtocolError(f"unexpected message type {message.T!r}")
107
+ return True
108
+
109
+ async def close(self, code: int) -> None:
110
+ if self.close_code is None:
111
+ self.close_code = code
112
+ await self.transport.close(code)
113
+
114
+ async def run_liveness(self) -> None:
115
+ """Send ``ping`` when idle and close the connection when the peer is silent."""
116
+ while self.close_code is None:
117
+ now = self._time()
118
+ idle_deadline = self._last_recv + self._idle_timeout
119
+ ping_deadline = self._last_send + self._ping_interval
120
+ if now >= idle_deadline:
121
+ _LOGGER.debug("peer silent for %.0f s, closing", self._idle_timeout)
122
+ await self.close(CLOSE_GOING_AWAY)
123
+ return
124
+ if now >= ping_deadline:
125
+ try:
126
+ await self.send(Ping(id=next(self._ping_ids)))
127
+ except ConnectionClosed:
128
+ return
129
+ continue
130
+ await asyncio.sleep(min(idle_deadline, ping_deadline) - now)
adaraz/aiohttp_ws.py ADDED
@@ -0,0 +1,80 @@
1
+ """aiohttp adapter (server and client side). Requires the ``aiohttp`` extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ import aiohttp
9
+ from aiohttp import WSMsgType, web
10
+
11
+ from .const import CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR, MAX_WS_MESSAGE, SUBPROTOCOL
12
+ from .exceptions import ConnectionClosed, ProtocolError
13
+ from .transport import build_ws_url
14
+
15
+ if TYPE_CHECKING:
16
+ from .server import LinkServer
17
+
18
+ __all__ = ["AiohttpTransport", "connect", "handle_request"]
19
+
20
+ _CLOSED = (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR)
21
+
22
+
23
+ class AiohttpTransport:
24
+ """:class:`adaraz.Transport` over an aiohttp WebSocket (either side)."""
25
+
26
+ def __init__(self, ws: web.WebSocketResponse | aiohttp.ClientWebSocketResponse) -> None:
27
+ self.ws = ws
28
+
29
+ @property
30
+ def close_code(self) -> int | None:
31
+ return self.ws.close_code
32
+
33
+ async def recv(self) -> bytes | None:
34
+ if self.ws.closed:
35
+ return None
36
+ msg = await self.ws.receive()
37
+ if msg.type == WSMsgType.BINARY:
38
+ data: bytes = msg.data
39
+ return data
40
+ if msg.type == WSMsgType.TEXT:
41
+ raise ProtocolError("text WebSocket message", CLOSE_PROTOCOL_ERROR)
42
+ if msg.type in _CLOSED:
43
+ return None
44
+ raise ProtocolError(f"unexpected WebSocket message type {msg.type!r}")
45
+
46
+ async def send(self, data: bytes) -> None:
47
+ if self.ws.closed:
48
+ raise ConnectionClosed("WebSocket closed")
49
+ try:
50
+ await self.ws.send_bytes(data)
51
+ except (ConnectionError, RuntimeError, aiohttp.ClientError) as err:
52
+ raise ConnectionClosed(str(err)) from err
53
+
54
+ async def close(self, code: int = CLOSE_NORMAL) -> None:
55
+ if self.ws.closed:
56
+ return
57
+ with contextlib.suppress(ConnectionError, RuntimeError, aiohttp.ClientError):
58
+ await self.ws.close(code=code)
59
+
60
+
61
+ async def handle_request(server: LinkServer, request: web.Request) -> web.WebSocketResponse:
62
+ """Accept a WebSocket request and run it as an ADARAZ Link connection."""
63
+ ws = web.WebSocketResponse(protocols=(SUBPROTOCOL,), max_msg_size=MAX_WS_MESSAGE, autoping=True)
64
+ await ws.prepare(request)
65
+ await server.handle_connection(AiohttpTransport(ws))
66
+ return ws
67
+
68
+
69
+ async def connect(
70
+ session: aiohttp.ClientSession, base_url: str, *, timeout: float = 10.0
71
+ ) -> AiohttpTransport:
72
+ """Open a client WebSocket to a server base URL (device side, for tests/tools)."""
73
+ ws = await session.ws_connect(
74
+ build_ws_url(base_url),
75
+ protocols=(SUBPROTOCOL,),
76
+ max_msg_size=MAX_WS_MESSAGE,
77
+ autoping=True,
78
+ timeout=aiohttp.ClientWSTimeout(ws_receive=None, ws_close=timeout),
79
+ )
80
+ return AiohttpTransport(ws)
adaraz/codes.py ADDED
@@ -0,0 +1,68 @@
1
+ """Pairing codes and PSK derivation (protocol section 2, "Pairing code and PSK")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
9
+
10
+ from .exceptions import InvalidCodeError
11
+
12
+ __all__ = [
13
+ "ALPHABET",
14
+ "CODE_LENGTH",
15
+ "GROUP_LENGTH",
16
+ "PSK_INFO",
17
+ "PSK_SALT",
18
+ "derive_psk",
19
+ "format_code",
20
+ "generate_code",
21
+ "normalize_code",
22
+ ]
23
+
24
+ #: Crockford Base32 alphabet.
25
+ ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
26
+ #: Number of symbols in a pairing code (60 bits).
27
+ CODE_LENGTH = 12
28
+ #: Symbols per group in the display form ``XXXX-XXXX-XXXX``.
29
+ GROUP_LENGTH = 4
30
+ PSK_SALT = b"ADARAZ pairing v1"
31
+ PSK_INFO = b"psk"
32
+
33
+ _MAP = str.maketrans({"O": "0", "I": "1", "L": "1"})
34
+
35
+
36
+ def generate_code() -> str:
37
+ """Return a new random pairing code formatted as ``XXXX-XXXX-XXXX``."""
38
+ raw = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
39
+ return format_code(raw)
40
+
41
+
42
+ def normalize_code(code: str) -> str:
43
+ """Normalise a code as typed by a person.
44
+
45
+ Uppercase, remove ``-`` and spaces, map ``O`` to ``0`` and ``I``/``L`` to ``1``.
46
+ Raises :class:`InvalidCodeError` if the result is not 12 alphabet symbols.
47
+ """
48
+ if not code.isascii():
49
+ raise InvalidCodeError("pairing code contains non-ASCII characters")
50
+ norm = code.upper().replace("-", "").replace(" ", "").translate(_MAP)
51
+ if len(norm) != CODE_LENGTH or any(ch not in ALPHABET for ch in norm):
52
+ raise InvalidCodeError(
53
+ f"pairing code must be {CODE_LENGTH} symbols of 0-9 and A-Z (without U)"
54
+ )
55
+ return norm
56
+
57
+
58
+ def format_code(code: str) -> str:
59
+ """Return the display form ``XXXX-XXXX-XXXX`` of a code (normalised first)."""
60
+ norm = normalize_code(code)
61
+ return "-".join(norm[i : i + GROUP_LENGTH] for i in range(0, CODE_LENGTH, GROUP_LENGTH))
62
+
63
+
64
+ def derive_psk(code: str) -> bytes:
65
+ """Derive the 32 byte Noise PSK from a pairing code (normalised first)."""
66
+ norm = normalize_code(code)
67
+ hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=PSK_SALT, info=PSK_INFO)
68
+ return hkdf.derive(norm.encode("ascii"))
adaraz/const.py ADDED
@@ -0,0 +1,56 @@
1
+ """Protocol constants (ADARAZ Link protocol, version 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ PROTOCOL_VERSION: Final = 1
8
+ SUBPROTOCOL: Final = "adaraz.v1"
9
+ DEFAULT_PATH: Final = "/api/adaraz/ws"
10
+
11
+ MODE_PAIR: Final = 0x50 # "P"
12
+ MODE_SESSION: Final = 0x53 # "S"
13
+
14
+ PROLOGUE_PAIR: Final = b"ADARAZ/1 pair"
15
+ PROLOGUE_SESSION: Final = b"ADARAZ/1 session"
16
+
17
+ #: Largest Noise message (payload plus 16 byte tag).
18
+ MAX_NOISE_MESSAGE: Final = 4096
19
+ #: Largest JSON payload of a transport message.
20
+ MAX_PAYLOAD: Final = MAX_NOISE_MESSAGE - 16
21
+ #: Largest WebSocket message accepted at the WebSocket layer. Anything above
22
+ #: MAX_NOISE_MESSAGE (+1 for the mode byte) and below this is closed with 4000 by the
23
+ #: protocol code; anything above this is closed by the WebSocket library.
24
+ MAX_WS_MESSAGE: Final = 65536
25
+
26
+ MAX_PUNCH_ITEMS: Final = 100
27
+ #: Largest log index, count, cursor position and punch timestamp (uint32).
28
+ MAX_UINT32: Final = 0xFFFFFFFF
29
+
30
+ CLOSE_NORMAL: Final = 1000
31
+ CLOSE_GOING_AWAY: Final = 1001
32
+ CLOSE_INTERNAL_ERROR: Final = 1011
33
+ CLOSE_PROTOCOL_ERROR: Final = 4000
34
+ CLOSE_UNKNOWN_DEVICE: Final = 4001
35
+ CLOSE_SUPERSEDED: Final = 4002
36
+ CLOSE_PAIRING_REJECTED: Final = 4003
37
+
38
+ PING_INTERVAL: Final = 30.0
39
+ IDLE_TIMEOUT: Final = 90.0
40
+ #: A session that is not established (the server has not decrypted the device's first
41
+ #: transport message) within this time is closed (protocol section 4).
42
+ ESTABLISH_TIMEOUT: Final = 10.0
43
+ #: Time the server has to send ``paired`` after pairing message 3.
44
+ PAIRED_DEADLINE: Final = 10.0
45
+ #: Pairing window lifetime and attempt limit.
46
+ PAIRING_WINDOW: Final = 600.0
47
+ PAIRING_MAX_FAILURES: Final = 5
48
+ #: Punches older than this at arrival are "late".
49
+ LATE_AFTER: Final = 300.0
50
+
51
+ RELAY_COMMANDS: Final = ("open", "on", "off", "pulse")
52
+ PULSE_MS_MIN: Final = 100
53
+ PULSE_MS_MAX: Final = 10000
54
+
55
+ CAP_PUNCHES: Final = "punches"
56
+ CAP_RELAY: Final = "relay"