adaraz 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of adaraz might be problematic. Click here for more details.
- adaraz-0.1.0/.gitignore +7 -0
- adaraz-0.1.0/LICENSE +21 -0
- adaraz-0.1.0/PKG-INFO +300 -0
- adaraz-0.1.0/README.md +267 -0
- adaraz-0.1.0/pyproject.toml +70 -0
- adaraz-0.1.0/src/adaraz/__init__.py +99 -0
- adaraz-0.1.0/src/adaraz/_channel.py +130 -0
- adaraz-0.1.0/src/adaraz/aiohttp_ws.py +80 -0
- adaraz-0.1.0/src/adaraz/codes.py +68 -0
- adaraz-0.1.0/src/adaraz/const.py +56 -0
- adaraz-0.1.0/src/adaraz/device.py +533 -0
- adaraz-0.1.0/src/adaraz/exceptions.py +76 -0
- adaraz-0.1.0/src/adaraz/messages.py +551 -0
- adaraz-0.1.0/src/adaraz/noise.py +464 -0
- adaraz-0.1.0/src/adaraz/py.typed +0 -0
- adaraz-0.1.0/src/adaraz/server.py +752 -0
- adaraz-0.1.0/src/adaraz/storage.py +197 -0
- adaraz-0.1.0/src/adaraz/transport.py +118 -0
- adaraz-0.1.0/tests/__init__.py +0 -0
- adaraz-0.1.0/tests/conftest.py +149 -0
- adaraz-0.1.0/tests/test_aiohttp.py +125 -0
- adaraz-0.1.0/tests/test_codes.py +81 -0
- adaraz-0.1.0/tests/test_messages.py +210 -0
- adaraz-0.1.0/tests/test_noise.py +203 -0
- adaraz-0.1.0/tests/test_pairing.py +248 -0
- adaraz-0.1.0/tests/test_session.py +825 -0
adaraz-0.1.0/.gitignore
ADDED
adaraz-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ADARAZ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
adaraz-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: adaraz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ADARAZ Link: secure, Noise based connection between ADARAZ devices (RAZ-RCP-1) and servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/ADARAZ-P-S-A/ADARAZ-HA
|
|
6
|
+
Project-URL: Documentation, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/tree/main/lib#readme
|
|
7
|
+
Project-URL: Protocol, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md
|
|
8
|
+
Project-URL: Issues, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/issues
|
|
9
|
+
Author: ADARAZ
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: adaraz,home assistant,noise,raz-rcp-1,time clock
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: cryptography>=42
|
|
25
|
+
Provides-Extra: aiohttp
|
|
26
|
+
Requires-Dist: aiohttp>=3.10; extra == 'aiohttp'
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: aiohttp>=3.10; extra == 'test'
|
|
29
|
+
Requires-Dist: noiseprotocol==0.3.1; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
31
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# adaraz
|
|
35
|
+
|
|
36
|
+
Python implementation of **ADARAZ Link**, the protocol that connects ADARAZ devices
|
|
37
|
+
(first: the RAZ-RCP-1 card reader / time clock) to a server. It is used by the Home
|
|
38
|
+
Assistant integration and can be used to build your own server.
|
|
39
|
+
|
|
40
|
+
- The device always connects out (WebSocket), the server never connects to a device.
|
|
41
|
+
- Every connection is a Noise session (`Noise_XXpsk3_25519_AESGCM_SHA256` for pairing,
|
|
42
|
+
`Noise_IK_25519_AESGCM_SHA256` for sessions) with mutual authentication, so plain
|
|
43
|
+
`ws://` in a LAN is safe.
|
|
44
|
+
- Pairing is confirmed with a one-time code that a person copies to the device.
|
|
45
|
+
- No punch is lost: delivery follows a cursor into the device log, and the server
|
|
46
|
+
acknowledges only after it stored the records.
|
|
47
|
+
|
|
48
|
+
The wire protocol is specified in
|
|
49
|
+
[`docs/protocol.md`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md).
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install adaraz # core (only depends on cryptography)
|
|
53
|
+
pip install "adaraz[aiohttp]" # plus the aiohttp WebSocket adapter
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Python 3.12 or newer. The library is fully typed (`py.typed`), async, and does no
|
|
57
|
+
blocking I/O in the event loop. It does not import Home Assistant.
|
|
58
|
+
|
|
59
|
+
## Quick start: a server
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from aiohttp import web
|
|
64
|
+
from adaraz import InMemoryStorage, LinkServer, PunchReceived, generate_private_key
|
|
65
|
+
|
|
66
|
+
async def main() -> None:
|
|
67
|
+
# Keep the private key and the storage persistent in real use: devices pin the
|
|
68
|
+
# server's public key at pairing.
|
|
69
|
+
server = LinkServer(generate_private_key(), InMemoryStorage(), server="My app", name="Office")
|
|
70
|
+
|
|
71
|
+
def on_event(event) -> None:
|
|
72
|
+
if isinstance(event, PunchReceived) and not event.punch.late:
|
|
73
|
+
print("card", event.punch.uid, "at", event.punch.ts, "on", event.device.device_id)
|
|
74
|
+
|
|
75
|
+
server.subscribe(on_event)
|
|
76
|
+
|
|
77
|
+
app = web.Application()
|
|
78
|
+
app.router.add_get("/api/adaraz/ws", server.handle_aiohttp)
|
|
79
|
+
runner = web.AppRunner(app)
|
|
80
|
+
await runner.setup()
|
|
81
|
+
await web.TCPSite(runner, "0.0.0.0", 8765).start()
|
|
82
|
+
|
|
83
|
+
window = server.open_pairing() # valid 10 minutes, one device
|
|
84
|
+
print("Enter http://<host>:8765 and code", window.code, "on the device")
|
|
85
|
+
device = await window # PairedDevice
|
|
86
|
+
print("paired", device.device_id)
|
|
87
|
+
|
|
88
|
+
await asyncio.sleep(5)
|
|
89
|
+
result = await server.send_relay(device.device_id, "pulse", ms=1500)
|
|
90
|
+
print("relay:", result.ok, result.err)
|
|
91
|
+
await asyncio.Event().wait()
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A complete standalone server with file storage and an admin HTTP API is in
|
|
97
|
+
[`examples/server.py`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/examples/server.py).
|
|
98
|
+
|
|
99
|
+
## Public API
|
|
100
|
+
|
|
101
|
+
Everything below is importable from `adaraz` unless a module is named.
|
|
102
|
+
|
|
103
|
+
### Keys and codes
|
|
104
|
+
|
|
105
|
+
| Name | Description |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `generate_private_key() -> bytes` | New X25519 private key (32 bytes). |
|
|
108
|
+
| `public_key_from_private(sk) -> bytes` | Matching public key. |
|
|
109
|
+
| `server_id(public_key) -> str` | Server identifier shown on the device (hex SHA-256, first 16 bytes). |
|
|
110
|
+
| `generate_code() -> str` | New random pairing code: 12 symbols of Crockford Base32 (60 bits), formatted `XXXX-XXXX-XXXX`. |
|
|
111
|
+
| `normalize_code(code) -> str` | Uppercase, remove `-` and spaces, map `O` to `0` and `I`/`L` to `1`; returns the 12 symbols without dashes. `InvalidCodeError` (a `ValueError`) if the result is not 12 alphabet symbols. |
|
|
112
|
+
| `format_code(code) -> str` | Normalise, then return the display form `XXXX-XXXX-XXXX`. |
|
|
113
|
+
| `derive_psk(code) -> bytes` | Noise PSK of a code (HKDF-SHA256 of the normalised code). Test vector: `0123-4567-89AB` gives `377690b4f263fcbe5c12a80d283e43febcb90b05326ca85477c0861940cf99a4`. |
|
|
114
|
+
|
|
115
|
+
`adaraz.codes` also exports `ALPHABET`, `CODE_LENGTH` (12) and `GROUP_LENGTH` (4).
|
|
116
|
+
|
|
117
|
+
### `LinkServer`
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
LinkServer(private_key: bytes, storage: LinkStorage, *, server="adaraz", name="",
|
|
121
|
+
ping_interval=30.0, idle_timeout=90.0, handshake_timeout=30.0,
|
|
122
|
+
establish_timeout=10.0, relay_timeout=10.0, late_after=300.0, clock=time.time)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- `public_key`, `server_id`, `storage`, `sessions`, `pairing_windows`
|
|
126
|
+
- `open_pairing(timeout=600, *, import_history=True) -> PairingWindow`
|
|
127
|
+
- `window.code` (show it), `window.expires_at`, `window.failures`, `window.active`
|
|
128
|
+
- `await window` (or `await window.wait()`) returns the `PairedDevice`, or raises
|
|
129
|
+
`PairingExpired`, `PairingFailed` (5 failed attempts, see below) or
|
|
130
|
+
`PairingCancelled`
|
|
131
|
+
- a **failed attempt** is a handshake that reached the code check: a well-formed
|
|
132
|
+
pairing message 3 (its static key decrypts, which does not depend on the code)
|
|
133
|
+
whose code protected part decrypts with none of the open windows. It counts
|
|
134
|
+
against every open window, since the server cannot tell which code the device
|
|
135
|
+
was given. A connection that ends, times out, or sends a malformed or oversized
|
|
136
|
+
message before that point is closed (4000) without counting. Note that anyone
|
|
137
|
+
who can reach the endpoint can still run 5 complete handshakes with a wrong code
|
|
138
|
+
and so close an open window (this is what limits online guessing); show the
|
|
139
|
+
`PairingFailed` outcome to the user and offer a new code.
|
|
140
|
+
- `window.cancel()`
|
|
141
|
+
- with `import_history` the device sends its whole log in the first session,
|
|
142
|
+
otherwise only records made after pairing
|
|
143
|
+
- `await handle_aiohttp(request) -> web.WebSocketResponse`: aiohttp handler for the
|
|
144
|
+
WebSocket endpoint (aiohttp is imported lazily)
|
|
145
|
+
- `await handle_connection(transport)`: run one connection over any `Transport`
|
|
146
|
+
- `get_session(device_id) -> DeviceSession | None`, `is_connected(device_id)`:
|
|
147
|
+
by the id stored at pairing (a device cannot change it later, see below)
|
|
148
|
+
- `get_session_by_key(public_key) -> DeviceSession | None`: by the device's static
|
|
149
|
+
public key, its actual identity
|
|
150
|
+
- `await send_relay(device_id, cmd, ms=None, *, timeout=None) -> RelayResult`:
|
|
151
|
+
`cmd` is `open`, `on`, `off` or `pulse` (`ms` 100-10000). Raises `ValueError`,
|
|
152
|
+
`DeviceNotConnected`, `RelayTimeout`. A refusal by the device is
|
|
153
|
+
`RelayResult(ok=False, err="not_allowed" | "invalid" | "busy")`.
|
|
154
|
+
- `await unpair(device_id)`: forget the device and tell it to delete its pairing.
|
|
155
|
+
Raises `DeviceNotFound`.
|
|
156
|
+
- `subscribe(listener) -> unsubscribe`: listeners get one event object (sync
|
|
157
|
+
callables, or coroutine functions which are run as tasks)
|
|
158
|
+
- `await close()`: close all sessions and pairing windows
|
|
159
|
+
|
|
160
|
+
Events (frozen dataclasses):
|
|
161
|
+
|
|
162
|
+
| Event | Fields |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `DevicePaired` | `device: PairedDevice` |
|
|
165
|
+
| `DeviceUnpaired` | `device` |
|
|
166
|
+
| `DeviceConnected` | `device`, `hello: Hello` (`fw`, `name`, `caps`, `log`); emitted when the session is established |
|
|
167
|
+
| `DeviceDisconnected` | `device`, `code: int \| None`; only for sessions that were established |
|
|
168
|
+
| `StateChanged` | `device`, `relay: bool`, `reader_ok: bool` |
|
|
169
|
+
| `PunchReceived` | `device`, `punch: PunchRecord` |
|
|
170
|
+
|
|
171
|
+
`PunchRecord` has `device_id`, `epoch`, `index`, `uid`, `ts`, `late`, `received_at` and
|
|
172
|
+
`key` (`(device_id, uid, ts)`). A late punch (arrived more than 5 minutes after `ts`)
|
|
173
|
+
belongs in history but should not trigger real-time actions.
|
|
174
|
+
|
|
175
|
+
`DeviceSession` (read-only): `device`, `hello`, `state`, `cursor`, `connected_at`,
|
|
176
|
+
`relay_allowed` (the device announced the `relay` capability).
|
|
177
|
+
|
|
178
|
+
Session rules implemented by the server:
|
|
179
|
+
|
|
180
|
+
- A device that is not paired, is paired with a different server key, or sends a
|
|
181
|
+
`hello.id` different from the id stored at pairing is closed with 4001. The device
|
|
182
|
+
is identified only by its static public key; `hello` updates `name`, `model` and
|
|
183
|
+
`fw` of the stored record, never `device_id`.
|
|
184
|
+
- A session is **established** only when the server has decrypted the device's
|
|
185
|
+
first transport message (the device sends `state` right after `welcome`). Session
|
|
186
|
+
message 1 can be replayed by anyone who recorded it, so before that point the
|
|
187
|
+
server changes nothing: no storage write, no event, no entry in `sessions` /
|
|
188
|
+
`get_session`, and an older session of the same device stays up. A session that is
|
|
189
|
+
not established within `establish_timeout` (10 s) is closed with 4000.
|
|
190
|
+
- When the new session is established, an older session of the same device is
|
|
191
|
+
closed with 4002 (its `DeviceDisconnected` is emitted before the new
|
|
192
|
+
`DeviceConnected`), the record is updated from `hello`, and a missing cursor is
|
|
193
|
+
set to the device's current count.
|
|
194
|
+
- `ping` after 30 s of silence, close with 1001 after 90 s without traffic.
|
|
195
|
+
- `ack` only after `storage.store_punches` returned. Empty `punches` batches (only
|
|
196
|
+
skipped indices) are stored and acknowledged like any other.
|
|
197
|
+
- Text frames, messages over 4096 bytes, bad JSON, unexpected types and integers
|
|
198
|
+
outside uint32 (log indices, counts, cursor positions, punch timestamps) close
|
|
199
|
+
with 4000.
|
|
200
|
+
|
|
201
|
+
### Storage
|
|
202
|
+
|
|
203
|
+
Implement `LinkStorage` (abstract base class, all methods async):
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
class LinkStorage:
|
|
207
|
+
async def list_devices(self) -> Sequence[PairedDevice]
|
|
208
|
+
async def get_device(self, public_key: bytes) -> PairedDevice | None # default uses list_devices
|
|
209
|
+
async def add_device(self, device: PairedDevice) -> None # insert or replace by key
|
|
210
|
+
async def remove_device(self, public_key: bytes) -> None # and its cursor
|
|
211
|
+
async def load_cursor(self, public_key: bytes) -> Cursor | None
|
|
212
|
+
async def save_cursor(self, public_key: bytes, cursor: Cursor) -> None
|
|
213
|
+
async def store_punches(self, device, punches: Sequence[PunchRecord], cursor: Cursor
|
|
214
|
+
) -> Sequence[PunchRecord] # returns the new ones
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`store_punches` must make the punches and the cursor durable before it returns and
|
|
218
|
+
return the punches that are not duplicates; `PunchDeduplicator` helps with that.
|
|
219
|
+
`InMemoryStorage` is a non-persistent implementation for tests.
|
|
220
|
+
|
|
221
|
+
A device id is unique: pairing a device whose id is already known under another key
|
|
222
|
+
(factory reset) removes the old record and closes its session. The id is set at
|
|
223
|
+
pairing and never changed by a session, so `device_id` lookups are stable; key
|
|
224
|
+
lookups (`get_device`, `get_session_by_key`) are the primary identity.
|
|
225
|
+
|
|
226
|
+
### Transport
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
class Transport(Protocol):
|
|
230
|
+
async def recv(self) -> bytes | None # None when closed; text frame -> ProtocolError
|
|
231
|
+
async def send(self, data: bytes) -> None
|
|
232
|
+
async def close(self, code: int = 1000) -> None
|
|
233
|
+
@property
|
|
234
|
+
def close_code(self) -> int | None
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- `adaraz.aiohttp_ws.AiohttpTransport(ws)` wraps an aiohttp server or client
|
|
238
|
+
WebSocket; `adaraz.aiohttp_ws.connect(session, base_url)` opens a client connection.
|
|
239
|
+
- `memory_pipe()` returns two connected in-memory transports (tests).
|
|
240
|
+
- `build_ws_url(base_url)` maps a configured server URL to the WebSocket URL.
|
|
241
|
+
|
|
242
|
+
### Simulated device: `adaraz.device`
|
|
243
|
+
|
|
244
|
+
`LinkDevice` is a complete device-side implementation, for testing servers without
|
|
245
|
+
hardware and as a reference for firmware:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from adaraz.aiohttp_ws import connect
|
|
249
|
+
from adaraz.device import LinkDevice
|
|
250
|
+
|
|
251
|
+
device = LinkDevice(device_id="C00F0202", relay_allowed=True)
|
|
252
|
+
await device.pair(await connect(http, "http://127.0.0.1:8765"), "ABCD-EFGH-1234")
|
|
253
|
+
task = asyncio.create_task(device.run_session(await connect(http, "http://127.0.0.1:8765")))
|
|
254
|
+
device.punch("04A1B2C3")
|
|
255
|
+
await device.wait_delivered()
|
|
256
|
+
await device.stop()
|
|
257
|
+
code = await task # WebSocket close code
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
It keeps an `EventLog` (`append`, `delete`, `purge`; deleting or purging changes the
|
|
261
|
+
epoch), follows the server cursor, sends `state` as its first transport message
|
|
262
|
+
right after `welcome`, sends batches of at most 100 records that fit in one 4096
|
|
263
|
+
byte Noise message (empty when every index is skipped), waits for each `ack`, and
|
|
264
|
+
after an epoch change waits for the `ack` in flight and starts again from index 0.
|
|
265
|
+
It reports `state`, executes relay commands (`not_allowed` unless `relay_allowed`
|
|
266
|
+
and `relay_enabled`, `on`/`off` cancel a pulse in progress, `busy` for
|
|
267
|
+
`open`/`pulse` within 1 s), and on `unpair` deletes its pairing and returns the relay
|
|
268
|
+
to rest. It does not reconnect by itself.
|
|
269
|
+
|
|
270
|
+
- `LinkDevice(..., relay_allowed=False, relay_enabled=True, ...)`: `relay_allowed`
|
|
271
|
+
is the administrator's remote relay consent (announced as the `relay` capability),
|
|
272
|
+
`relay_enabled` is the local relay setting (False: relay disabled).
|
|
273
|
+
- `await device.set_relay_allowed(allowed)`: revoking consent returns the relay to
|
|
274
|
+
rest and reports `state`.
|
|
275
|
+
- `device.delete_record(index)`, `device.purge_log()`: change the log (new epoch)
|
|
276
|
+
and restart delivery during a running session.
|
|
277
|
+
|
|
278
|
+
### Low level
|
|
279
|
+
|
|
280
|
+
`adaraz.noise` (Noise `CipherState`, `SymmetricState`, `HandshakeState`, patterns
|
|
281
|
+
`XXPSK3` and `IK`) and `adaraz.messages` (payload dataclasses, `encode`, `decode`) are
|
|
282
|
+
public too, for tools and tests. `adaraz.const` holds the protocol constants (e.g.
|
|
283
|
+
`ESTABLISH_TIMEOUT`, `MAX_UINT32`).
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
cd lib
|
|
289
|
+
pip install -e ".[test]" mypy ruff
|
|
290
|
+
pytest
|
|
291
|
+
mypy
|
|
292
|
+
ruff check src tests
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The Noise implementation is tested for interoperability against the reference
|
|
296
|
+
library `noiseprotocol` for both patterns in both roles.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
adaraz-0.1.0/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# adaraz
|
|
2
|
+
|
|
3
|
+
Python implementation of **ADARAZ Link**, the protocol that connects ADARAZ devices
|
|
4
|
+
(first: the RAZ-RCP-1 card reader / time clock) to a server. It is used by the Home
|
|
5
|
+
Assistant integration and can be used to build your own server.
|
|
6
|
+
|
|
7
|
+
- The device always connects out (WebSocket), the server never connects to a device.
|
|
8
|
+
- Every connection is a Noise session (`Noise_XXpsk3_25519_AESGCM_SHA256` for pairing,
|
|
9
|
+
`Noise_IK_25519_AESGCM_SHA256` for sessions) with mutual authentication, so plain
|
|
10
|
+
`ws://` in a LAN is safe.
|
|
11
|
+
- Pairing is confirmed with a one-time code that a person copies to the device.
|
|
12
|
+
- No punch is lost: delivery follows a cursor into the device log, and the server
|
|
13
|
+
acknowledges only after it stored the records.
|
|
14
|
+
|
|
15
|
+
The wire protocol is specified in
|
|
16
|
+
[`docs/protocol.md`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md).
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
pip install adaraz # core (only depends on cryptography)
|
|
20
|
+
pip install "adaraz[aiohttp]" # plus the aiohttp WebSocket adapter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Python 3.12 or newer. The library is fully typed (`py.typed`), async, and does no
|
|
24
|
+
blocking I/O in the event loop. It does not import Home Assistant.
|
|
25
|
+
|
|
26
|
+
## Quick start: a server
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from aiohttp import web
|
|
31
|
+
from adaraz import InMemoryStorage, LinkServer, PunchReceived, generate_private_key
|
|
32
|
+
|
|
33
|
+
async def main() -> None:
|
|
34
|
+
# Keep the private key and the storage persistent in real use: devices pin the
|
|
35
|
+
# server's public key at pairing.
|
|
36
|
+
server = LinkServer(generate_private_key(), InMemoryStorage(), server="My app", name="Office")
|
|
37
|
+
|
|
38
|
+
def on_event(event) -> None:
|
|
39
|
+
if isinstance(event, PunchReceived) and not event.punch.late:
|
|
40
|
+
print("card", event.punch.uid, "at", event.punch.ts, "on", event.device.device_id)
|
|
41
|
+
|
|
42
|
+
server.subscribe(on_event)
|
|
43
|
+
|
|
44
|
+
app = web.Application()
|
|
45
|
+
app.router.add_get("/api/adaraz/ws", server.handle_aiohttp)
|
|
46
|
+
runner = web.AppRunner(app)
|
|
47
|
+
await runner.setup()
|
|
48
|
+
await web.TCPSite(runner, "0.0.0.0", 8765).start()
|
|
49
|
+
|
|
50
|
+
window = server.open_pairing() # valid 10 minutes, one device
|
|
51
|
+
print("Enter http://<host>:8765 and code", window.code, "on the device")
|
|
52
|
+
device = await window # PairedDevice
|
|
53
|
+
print("paired", device.device_id)
|
|
54
|
+
|
|
55
|
+
await asyncio.sleep(5)
|
|
56
|
+
result = await server.send_relay(device.device_id, "pulse", ms=1500)
|
|
57
|
+
print("relay:", result.ok, result.err)
|
|
58
|
+
await asyncio.Event().wait()
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A complete standalone server with file storage and an admin HTTP API is in
|
|
64
|
+
[`examples/server.py`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/examples/server.py).
|
|
65
|
+
|
|
66
|
+
## Public API
|
|
67
|
+
|
|
68
|
+
Everything below is importable from `adaraz` unless a module is named.
|
|
69
|
+
|
|
70
|
+
### Keys and codes
|
|
71
|
+
|
|
72
|
+
| Name | Description |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `generate_private_key() -> bytes` | New X25519 private key (32 bytes). |
|
|
75
|
+
| `public_key_from_private(sk) -> bytes` | Matching public key. |
|
|
76
|
+
| `server_id(public_key) -> str` | Server identifier shown on the device (hex SHA-256, first 16 bytes). |
|
|
77
|
+
| `generate_code() -> str` | New random pairing code: 12 symbols of Crockford Base32 (60 bits), formatted `XXXX-XXXX-XXXX`. |
|
|
78
|
+
| `normalize_code(code) -> str` | Uppercase, remove `-` and spaces, map `O` to `0` and `I`/`L` to `1`; returns the 12 symbols without dashes. `InvalidCodeError` (a `ValueError`) if the result is not 12 alphabet symbols. |
|
|
79
|
+
| `format_code(code) -> str` | Normalise, then return the display form `XXXX-XXXX-XXXX`. |
|
|
80
|
+
| `derive_psk(code) -> bytes` | Noise PSK of a code (HKDF-SHA256 of the normalised code). Test vector: `0123-4567-89AB` gives `377690b4f263fcbe5c12a80d283e43febcb90b05326ca85477c0861940cf99a4`. |
|
|
81
|
+
|
|
82
|
+
`adaraz.codes` also exports `ALPHABET`, `CODE_LENGTH` (12) and `GROUP_LENGTH` (4).
|
|
83
|
+
|
|
84
|
+
### `LinkServer`
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
LinkServer(private_key: bytes, storage: LinkStorage, *, server="adaraz", name="",
|
|
88
|
+
ping_interval=30.0, idle_timeout=90.0, handshake_timeout=30.0,
|
|
89
|
+
establish_timeout=10.0, relay_timeout=10.0, late_after=300.0, clock=time.time)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `public_key`, `server_id`, `storage`, `sessions`, `pairing_windows`
|
|
93
|
+
- `open_pairing(timeout=600, *, import_history=True) -> PairingWindow`
|
|
94
|
+
- `window.code` (show it), `window.expires_at`, `window.failures`, `window.active`
|
|
95
|
+
- `await window` (or `await window.wait()`) returns the `PairedDevice`, or raises
|
|
96
|
+
`PairingExpired`, `PairingFailed` (5 failed attempts, see below) or
|
|
97
|
+
`PairingCancelled`
|
|
98
|
+
- a **failed attempt** is a handshake that reached the code check: a well-formed
|
|
99
|
+
pairing message 3 (its static key decrypts, which does not depend on the code)
|
|
100
|
+
whose code protected part decrypts with none of the open windows. It counts
|
|
101
|
+
against every open window, since the server cannot tell which code the device
|
|
102
|
+
was given. A connection that ends, times out, or sends a malformed or oversized
|
|
103
|
+
message before that point is closed (4000) without counting. Note that anyone
|
|
104
|
+
who can reach the endpoint can still run 5 complete handshakes with a wrong code
|
|
105
|
+
and so close an open window (this is what limits online guessing); show the
|
|
106
|
+
`PairingFailed` outcome to the user and offer a new code.
|
|
107
|
+
- `window.cancel()`
|
|
108
|
+
- with `import_history` the device sends its whole log in the first session,
|
|
109
|
+
otherwise only records made after pairing
|
|
110
|
+
- `await handle_aiohttp(request) -> web.WebSocketResponse`: aiohttp handler for the
|
|
111
|
+
WebSocket endpoint (aiohttp is imported lazily)
|
|
112
|
+
- `await handle_connection(transport)`: run one connection over any `Transport`
|
|
113
|
+
- `get_session(device_id) -> DeviceSession | None`, `is_connected(device_id)`:
|
|
114
|
+
by the id stored at pairing (a device cannot change it later, see below)
|
|
115
|
+
- `get_session_by_key(public_key) -> DeviceSession | None`: by the device's static
|
|
116
|
+
public key, its actual identity
|
|
117
|
+
- `await send_relay(device_id, cmd, ms=None, *, timeout=None) -> RelayResult`:
|
|
118
|
+
`cmd` is `open`, `on`, `off` or `pulse` (`ms` 100-10000). Raises `ValueError`,
|
|
119
|
+
`DeviceNotConnected`, `RelayTimeout`. A refusal by the device is
|
|
120
|
+
`RelayResult(ok=False, err="not_allowed" | "invalid" | "busy")`.
|
|
121
|
+
- `await unpair(device_id)`: forget the device and tell it to delete its pairing.
|
|
122
|
+
Raises `DeviceNotFound`.
|
|
123
|
+
- `subscribe(listener) -> unsubscribe`: listeners get one event object (sync
|
|
124
|
+
callables, or coroutine functions which are run as tasks)
|
|
125
|
+
- `await close()`: close all sessions and pairing windows
|
|
126
|
+
|
|
127
|
+
Events (frozen dataclasses):
|
|
128
|
+
|
|
129
|
+
| Event | Fields |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `DevicePaired` | `device: PairedDevice` |
|
|
132
|
+
| `DeviceUnpaired` | `device` |
|
|
133
|
+
| `DeviceConnected` | `device`, `hello: Hello` (`fw`, `name`, `caps`, `log`); emitted when the session is established |
|
|
134
|
+
| `DeviceDisconnected` | `device`, `code: int \| None`; only for sessions that were established |
|
|
135
|
+
| `StateChanged` | `device`, `relay: bool`, `reader_ok: bool` |
|
|
136
|
+
| `PunchReceived` | `device`, `punch: PunchRecord` |
|
|
137
|
+
|
|
138
|
+
`PunchRecord` has `device_id`, `epoch`, `index`, `uid`, `ts`, `late`, `received_at` and
|
|
139
|
+
`key` (`(device_id, uid, ts)`). A late punch (arrived more than 5 minutes after `ts`)
|
|
140
|
+
belongs in history but should not trigger real-time actions.
|
|
141
|
+
|
|
142
|
+
`DeviceSession` (read-only): `device`, `hello`, `state`, `cursor`, `connected_at`,
|
|
143
|
+
`relay_allowed` (the device announced the `relay` capability).
|
|
144
|
+
|
|
145
|
+
Session rules implemented by the server:
|
|
146
|
+
|
|
147
|
+
- A device that is not paired, is paired with a different server key, or sends a
|
|
148
|
+
`hello.id` different from the id stored at pairing is closed with 4001. The device
|
|
149
|
+
is identified only by its static public key; `hello` updates `name`, `model` and
|
|
150
|
+
`fw` of the stored record, never `device_id`.
|
|
151
|
+
- A session is **established** only when the server has decrypted the device's
|
|
152
|
+
first transport message (the device sends `state` right after `welcome`). Session
|
|
153
|
+
message 1 can be replayed by anyone who recorded it, so before that point the
|
|
154
|
+
server changes nothing: no storage write, no event, no entry in `sessions` /
|
|
155
|
+
`get_session`, and an older session of the same device stays up. A session that is
|
|
156
|
+
not established within `establish_timeout` (10 s) is closed with 4000.
|
|
157
|
+
- When the new session is established, an older session of the same device is
|
|
158
|
+
closed with 4002 (its `DeviceDisconnected` is emitted before the new
|
|
159
|
+
`DeviceConnected`), the record is updated from `hello`, and a missing cursor is
|
|
160
|
+
set to the device's current count.
|
|
161
|
+
- `ping` after 30 s of silence, close with 1001 after 90 s without traffic.
|
|
162
|
+
- `ack` only after `storage.store_punches` returned. Empty `punches` batches (only
|
|
163
|
+
skipped indices) are stored and acknowledged like any other.
|
|
164
|
+
- Text frames, messages over 4096 bytes, bad JSON, unexpected types and integers
|
|
165
|
+
outside uint32 (log indices, counts, cursor positions, punch timestamps) close
|
|
166
|
+
with 4000.
|
|
167
|
+
|
|
168
|
+
### Storage
|
|
169
|
+
|
|
170
|
+
Implement `LinkStorage` (abstract base class, all methods async):
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
class LinkStorage:
|
|
174
|
+
async def list_devices(self) -> Sequence[PairedDevice]
|
|
175
|
+
async def get_device(self, public_key: bytes) -> PairedDevice | None # default uses list_devices
|
|
176
|
+
async def add_device(self, device: PairedDevice) -> None # insert or replace by key
|
|
177
|
+
async def remove_device(self, public_key: bytes) -> None # and its cursor
|
|
178
|
+
async def load_cursor(self, public_key: bytes) -> Cursor | None
|
|
179
|
+
async def save_cursor(self, public_key: bytes, cursor: Cursor) -> None
|
|
180
|
+
async def store_punches(self, device, punches: Sequence[PunchRecord], cursor: Cursor
|
|
181
|
+
) -> Sequence[PunchRecord] # returns the new ones
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`store_punches` must make the punches and the cursor durable before it returns and
|
|
185
|
+
return the punches that are not duplicates; `PunchDeduplicator` helps with that.
|
|
186
|
+
`InMemoryStorage` is a non-persistent implementation for tests.
|
|
187
|
+
|
|
188
|
+
A device id is unique: pairing a device whose id is already known under another key
|
|
189
|
+
(factory reset) removes the old record and closes its session. The id is set at
|
|
190
|
+
pairing and never changed by a session, so `device_id` lookups are stable; key
|
|
191
|
+
lookups (`get_device`, `get_session_by_key`) are the primary identity.
|
|
192
|
+
|
|
193
|
+
### Transport
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
class Transport(Protocol):
|
|
197
|
+
async def recv(self) -> bytes | None # None when closed; text frame -> ProtocolError
|
|
198
|
+
async def send(self, data: bytes) -> None
|
|
199
|
+
async def close(self, code: int = 1000) -> None
|
|
200
|
+
@property
|
|
201
|
+
def close_code(self) -> int | None
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- `adaraz.aiohttp_ws.AiohttpTransport(ws)` wraps an aiohttp server or client
|
|
205
|
+
WebSocket; `adaraz.aiohttp_ws.connect(session, base_url)` opens a client connection.
|
|
206
|
+
- `memory_pipe()` returns two connected in-memory transports (tests).
|
|
207
|
+
- `build_ws_url(base_url)` maps a configured server URL to the WebSocket URL.
|
|
208
|
+
|
|
209
|
+
### Simulated device: `adaraz.device`
|
|
210
|
+
|
|
211
|
+
`LinkDevice` is a complete device-side implementation, for testing servers without
|
|
212
|
+
hardware and as a reference for firmware:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from adaraz.aiohttp_ws import connect
|
|
216
|
+
from adaraz.device import LinkDevice
|
|
217
|
+
|
|
218
|
+
device = LinkDevice(device_id="C00F0202", relay_allowed=True)
|
|
219
|
+
await device.pair(await connect(http, "http://127.0.0.1:8765"), "ABCD-EFGH-1234")
|
|
220
|
+
task = asyncio.create_task(device.run_session(await connect(http, "http://127.0.0.1:8765")))
|
|
221
|
+
device.punch("04A1B2C3")
|
|
222
|
+
await device.wait_delivered()
|
|
223
|
+
await device.stop()
|
|
224
|
+
code = await task # WebSocket close code
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
It keeps an `EventLog` (`append`, `delete`, `purge`; deleting or purging changes the
|
|
228
|
+
epoch), follows the server cursor, sends `state` as its first transport message
|
|
229
|
+
right after `welcome`, sends batches of at most 100 records that fit in one 4096
|
|
230
|
+
byte Noise message (empty when every index is skipped), waits for each `ack`, and
|
|
231
|
+
after an epoch change waits for the `ack` in flight and starts again from index 0.
|
|
232
|
+
It reports `state`, executes relay commands (`not_allowed` unless `relay_allowed`
|
|
233
|
+
and `relay_enabled`, `on`/`off` cancel a pulse in progress, `busy` for
|
|
234
|
+
`open`/`pulse` within 1 s), and on `unpair` deletes its pairing and returns the relay
|
|
235
|
+
to rest. It does not reconnect by itself.
|
|
236
|
+
|
|
237
|
+
- `LinkDevice(..., relay_allowed=False, relay_enabled=True, ...)`: `relay_allowed`
|
|
238
|
+
is the administrator's remote relay consent (announced as the `relay` capability),
|
|
239
|
+
`relay_enabled` is the local relay setting (False: relay disabled).
|
|
240
|
+
- `await device.set_relay_allowed(allowed)`: revoking consent returns the relay to
|
|
241
|
+
rest and reports `state`.
|
|
242
|
+
- `device.delete_record(index)`, `device.purge_log()`: change the log (new epoch)
|
|
243
|
+
and restart delivery during a running session.
|
|
244
|
+
|
|
245
|
+
### Low level
|
|
246
|
+
|
|
247
|
+
`adaraz.noise` (Noise `CipherState`, `SymmetricState`, `HandshakeState`, patterns
|
|
248
|
+
`XXPSK3` and `IK`) and `adaraz.messages` (payload dataclasses, `encode`, `decode`) are
|
|
249
|
+
public too, for tools and tests. `adaraz.const` holds the protocol constants (e.g.
|
|
250
|
+
`ESTABLISH_TIMEOUT`, `MAX_UINT32`).
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
cd lib
|
|
256
|
+
pip install -e ".[test]" mypy ruff
|
|
257
|
+
pytest
|
|
258
|
+
mypy
|
|
259
|
+
ruff check src tests
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The Noise implementation is tested for interoperability against the reference
|
|
263
|
+
library `noiseprotocol` for both patterns in both roles.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "adaraz"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "ADARAZ Link: secure, Noise based connection between ADARAZ devices (RAZ-RCP-1) and servers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [{ name = "ADARAZ" }]
|
|
14
|
+
keywords = ["adaraz", "raz-rcp-1", "noise", "time clock", "home assistant"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Topic :: Security :: Cryptography",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["cryptography>=42"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
aiohttp = ["aiohttp>=3.10"]
|
|
31
|
+
test = [
|
|
32
|
+
"aiohttp>=3.10",
|
|
33
|
+
"noiseprotocol==0.3.1",
|
|
34
|
+
"pytest>=8",
|
|
35
|
+
"pytest-asyncio>=0.24",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA"
|
|
40
|
+
Documentation = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/tree/main/lib#readme"
|
|
41
|
+
Protocol = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md"
|
|
42
|
+
Issues = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/issues"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/adaraz"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = ["src/adaraz", "tests", "README.md", "LICENSE"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
asyncio_mode = "auto"
|
|
52
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
python_version = "3.12"
|
|
58
|
+
files = ["src/adaraz"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
target-version = "py312"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "ASYNC"]
|
|
66
|
+
# Public coroutines take an explicit timeout argument on purpose.
|
|
67
|
+
ignore = ["ASYNC109"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
"tests/*" = ["ASYNC110"]
|