pyown 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.
pyown/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .client import Client
pyown/auth/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .enum import *
2
+ from .hmac import *
3
+ from .open import *
pyown/auth/enum.py ADDED
@@ -0,0 +1,46 @@
1
+ from enum import IntEnum
2
+
3
+ from ..messages import GenericMessage
4
+
5
+ __all__ = [
6
+ "AuthAlgorithm",
7
+ ]
8
+
9
+
10
+ class AuthAlgorithm(IntEnum):
11
+ """
12
+ Represents the all allowed hashing algorithms when using the HMAC authentication algorithm.
13
+
14
+ Attributes:
15
+ SHA1: The SHA1 hashing algorithm.
16
+ SHA256: The SHA256 hashing algorithm
17
+ """
18
+ SHA1 = 1
19
+ SHA256 = 2
20
+
21
+ def to_message(self) -> GenericMessage:
22
+ """
23
+ Converts the AuthAlgorithm to a message.
24
+
25
+ Returns:
26
+ GenericMessage: The message.
27
+ """
28
+ return GenericMessage(["98", str(self.value)])
29
+
30
+ @classmethod
31
+ def from_string(cls, value: str) -> "AuthAlgorithm":
32
+ """
33
+ Converts a string to an AuthAlgorithm.
34
+
35
+ Args:
36
+ value: The string to convert.
37
+
38
+ Returns:
39
+ AuthAlgorithm: The corresponding AuthAlgorithm.
40
+ """
41
+ if value == "1":
42
+ return cls.SHA1
43
+ elif value == "2":
44
+ return cls.SHA256
45
+ else:
46
+ raise ValueError("Invalid hash algorithm")
pyown/auth/hmac.py ADDED
@@ -0,0 +1,169 @@
1
+ from hashlib import sha1, sha256
2
+ from hmac import HMAC, compare_digest
3
+ from secrets import token_bytes
4
+
5
+ from .enum import AuthAlgorithm
6
+
7
+ __all__ = [
8
+ "client_hmac",
9
+ "server_hmac",
10
+ "compare_hmac",
11
+ "create_key",
12
+ "hex_to_digits",
13
+ "digits_to_hex",
14
+ ]
15
+
16
+
17
+ def client_hmac(
18
+ server_key: str,
19
+ client_key: str,
20
+ password: str,
21
+ client_identity: str = "736F70653E",
22
+ server_identity: str = "636F70653E",
23
+ hash_algorithm: AuthAlgorithm = AuthAlgorithm.SHA256,
24
+ ) -> bytes:
25
+ """
26
+ Generates the HMAC authentication for the client.
27
+
28
+ Args:
29
+ server_key: The key sent by the server (Ra)
30
+ client_key: The key generated by the client (Rb)
31
+ password: The open password of the server (Kab = sha(kab))
32
+ client_identity: string used to identify the client (A)
33
+ server_identity: string used to identify the server (B)
34
+ hash_algorithm: The hash function to use for the hmac calculation (can be sha1 or sha256)
35
+
36
+ Returns:
37
+ str: the client authentication string in bytes
38
+ """
39
+ if hash_algorithm == AuthAlgorithm.SHA1:
40
+ hash_function = sha1
41
+ elif hash_algorithm == AuthAlgorithm.SHA256:
42
+ hash_function = sha256
43
+ else:
44
+ raise ValueError("Invalid hash algorithm")
45
+
46
+ kab = hash_function(password.encode()).hexdigest()
47
+ hmac_message = f"{server_key}{client_key}{client_identity}{server_identity}{kab}"
48
+
49
+ hmac = HMAC(
50
+ key=server_key.encode(),
51
+ msg=hmac_message.encode(),
52
+ digestmod=hash_function,
53
+ )
54
+
55
+ return hmac.digest()
56
+
57
+
58
+ def server_hmac(
59
+ server_key: str,
60
+ client_key: str,
61
+ password: str,
62
+ hash_algorithm: AuthAlgorithm = AuthAlgorithm.SHA256,
63
+ ) -> bytes:
64
+ """
65
+ Generates the HMAC authentication for the server.
66
+
67
+ Args:
68
+ server_key: The key sent by the server (Ra)
69
+ client_key: The key generated by the client (Rb)
70
+ password: The open password of the server (Kab = sha(kab))
71
+ hash_algorithm: The hash function to use for the hmac calculation (can be sha1 or sha256)
72
+
73
+ Returns:
74
+ str: the server confirmation string in bytes
75
+ """
76
+ if hash_algorithm == AuthAlgorithm.SHA1:
77
+ hash_function = sha1
78
+ elif hash_algorithm == AuthAlgorithm.SHA256:
79
+ hash_function = sha256
80
+ else:
81
+ raise ValueError("Invalid hash algorithm")
82
+
83
+ kab = hash_function(password.encode()).hexdigest()
84
+ hmac_message = f"{client_key}{server_key}{kab}"
85
+
86
+ hmac = HMAC(
87
+ key=server_key.encode(),
88
+ msg=hmac_message.encode(),
89
+ digestmod=hash_function,
90
+ )
91
+
92
+ return hmac.digest()
93
+
94
+
95
+ def compare_hmac(
96
+ hmac1: bytes,
97
+ hmac2: bytes,
98
+ ) -> bool:
99
+ """
100
+ Compares two hmacs in constant time.
101
+
102
+ Args:
103
+ hmac1: The first hmac
104
+ hmac2: The second hmac
105
+
106
+ Returns:
107
+ bool: True if the hmacs are equal, False otherwise
108
+ """
109
+ return compare_digest(hmac1, hmac2)
110
+
111
+
112
+ def create_key(
113
+ hash_algorithm: AuthAlgorithm = AuthAlgorithm.SHA256,
114
+ ) -> str:
115
+ """
116
+ Creates a random key for the hmac.
117
+
118
+ Args:
119
+ hash_algorithm: The hash function to use for the hmac calculation (can be sha1 or sha256)
120
+
121
+ Returns:
122
+ str: the key in hex format
123
+ """
124
+ if hash_algorithm == AuthAlgorithm.SHA1:
125
+ hash_function = sha1
126
+ elif hash_algorithm == AuthAlgorithm.SHA256:
127
+ hash_function = sha256
128
+ else:
129
+ raise ValueError("Invalid hash algorithm")
130
+
131
+ return hash_function(token_bytes(32)).hexdigest()
132
+
133
+
134
+ def hex_to_digits(
135
+ hex_string: str,
136
+ ) -> str:
137
+ """
138
+ Converts a hex string to digits.
139
+
140
+ Args:
141
+ hex_string: The hex string
142
+
143
+ Returns:
144
+ str: the digit string
145
+ """
146
+ out = ""
147
+ for c in hex_string:
148
+ value = int(c, 16)
149
+ out += str(value // 10) + str(value % 10)
150
+ return out
151
+
152
+
153
+ def digits_to_hex(
154
+ digits_string: str,
155
+ ) -> str:
156
+ """
157
+ Converts a digit string to hex.
158
+
159
+ Args:
160
+ digits_string: The digit string
161
+
162
+ Returns:
163
+ str: the hex string
164
+ """
165
+ out = ""
166
+ for i in range(0, len(digits_string), 2):
167
+ value = int(digits_string[i]) * 10 + int(digits_string[i + 1])
168
+ out += hex(value)[2:]
169
+ return out
pyown/auth/open.py ADDED
@@ -0,0 +1,66 @@
1
+ __all__ = ["own_calc_pass"]
2
+
3
+
4
+ def own_calc_pass(password: str | int, nonce: str | int) -> str: # noqa: C901
5
+ """
6
+ Encodes the password using the OPEN algorithm.
7
+ Source: https://rosettacode.org/wiki/OpenWebNet_password#Python
8
+
9
+ Parameters:
10
+ password (str | int): The password to encode must be composed of only digits.
11
+ nonce (str): The nonce received from the gateway.
12
+
13
+ Returns:
14
+ str: The encoded password.
15
+ """
16
+ start = True
17
+ num1 = 0
18
+ num2 = 0
19
+
20
+ if isinstance(password, str):
21
+ password = int(password)
22
+
23
+ if isinstance(nonce, int):
24
+ nonce = str(nonce)
25
+
26
+ for c in nonce:
27
+ if c != "0":
28
+ if start:
29
+ num2 = password
30
+ start = False
31
+ if c == '1':
32
+ num1 = (num2 & 0xFFFFFF80) >> 7
33
+ num2 = num2 << 25
34
+ elif c == '2':
35
+ num1 = (num2 & 0xFFFFFFF0) >> 4
36
+ num2 = num2 << 28
37
+ elif c == '3':
38
+ num1 = (num2 & 0xFFFFFFF8) >> 3
39
+ num2 = num2 << 29
40
+ elif c == '4':
41
+ num1 = num2 << 1
42
+ num2 = num2 >> 31
43
+ elif c == '5':
44
+ num1 = num2 << 5
45
+ num2 = num2 >> 27
46
+ elif c == '6':
47
+ num1 = num2 << 12
48
+ num2 = num2 >> 20
49
+ elif c == '7':
50
+ num1 = num2 & 0x0000FF00 | ((num2 & 0x000000FF) << 24) | ((num2 & 0x00FF0000) >> 16)
51
+ num2 = (num2 & 0xFF000000) >> 8
52
+ elif c == '8':
53
+ num1 = (num2 & 0x0000FFFF) << 16 | (num2 >> 24)
54
+ num2 = (num2 & 0x00FF0000) >> 8
55
+ elif c == '9':
56
+ num1 = ~num2
57
+ else:
58
+ num1 = num2
59
+
60
+ num1 &= 0xFFFFFFFF
61
+ num2 &= 0xFFFFFFFF
62
+ if c not in "09":
63
+ num1 |= num2
64
+ num2 = num1
65
+
66
+ return str(num1)
@@ -0,0 +1,3 @@
1
+ from .base import BaseClient
2
+ from .client import Client
3
+ from .session import SessionType
pyown/client/base.py ADDED
@@ -0,0 +1,267 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from asyncio import AbstractEventLoop, Transport, Future
5
+ from typing import Optional, Any
6
+
7
+ from .session import SessionType
8
+ from ..auth import AuthAlgorithm
9
+ from ..auth.hmac import server_hmac, client_hmac, hex_to_digits, compare_hmac, create_key
10
+ from ..auth.open import own_calc_pass
11
+ from ..exceptions import InvalidAuthentication, InvalidSession
12
+ from ..messages import BaseMessage, MessageType, GenericMessage, NACK, ACK
13
+ from ..protocol import OWNProtocol
14
+
15
+ __all__ = [
16
+ "BaseClient",
17
+ ]
18
+
19
+ log = logging.getLogger("pyown.client")
20
+
21
+
22
+ class BaseClient(ABC):
23
+ def __init__(
24
+ self,
25
+ host: str,
26
+ port: int,
27
+ password: str,
28
+ session_type: SessionType = SessionType.CommandSession,
29
+ *,
30
+ loop: Optional[AbstractEventLoop] = None
31
+ ):
32
+ """
33
+ BaseClient constructor
34
+ This class should not be instantiated directly, use the Client class instead
35
+
36
+ Args:
37
+ host (str): The host to connect to (ip address)
38
+ port (int): The port to connect to
39
+ password (str): The password to authenticate with
40
+ session_type (SessionType): The session type to use
41
+ loop (Optional[AbstractEventLoop]): The event loop to use
42
+ """
43
+ self._host = host
44
+ self._port = port
45
+ self._password = password
46
+ self._session_type = session_type
47
+
48
+ self._transport: Optional[Transport] = None
49
+ self._protocol: Optional[OWNProtocol] = None
50
+
51
+ self._loop = loop or asyncio.get_event_loop()
52
+ self._loop.set_exception_handler(self._exception_handler)
53
+
54
+ self._on_connection_start: Future[Transport] = self._loop.create_future()
55
+ self._on_connection_end: Future[Exception | None] = self._loop.create_future()
56
+
57
+ def is_cmd_session(self) -> bool:
58
+ """
59
+ Checks if the session is a command session
60
+
61
+ Returns:
62
+ bool: True if the session is a command session, False otherwise
63
+ """
64
+ return self._session_type == SessionType.CommandSession or self._session_type == SessionType.OldCommandSession
65
+
66
+ async def start(self) -> None:
67
+ """
68
+ Creates a connection with the gateway and does the initial handshake
69
+
70
+ Raises:
71
+ TimeoutError: if the server does not respond
72
+ InvalidSession: if the server requires an unknown authentication algorithm
73
+ """
74
+ self._transport, self._protocol = await self._loop.create_connection(
75
+ lambda: OWNProtocol(
76
+ on_connection_start=self._on_connection_start,
77
+ on_connection_end=self._on_connection_end,
78
+ ),
79
+ self._host,
80
+ self._port
81
+ )
82
+
83
+ # Wait for the connection to start
84
+ await self._on_connection_start
85
+
86
+ log.debug("Connection started")
87
+
88
+ # Handshake
89
+ # The first packet is from the server, and it's an ACK packet
90
+ # The second packet is from the client and set the session type
91
+ # Wait for the first packet
92
+ async with asyncio.timeout(5):
93
+ message = await self.read_message()
94
+
95
+ if message.type != MessageType.ACK:
96
+ raise InvalidAuthentication("Expected ACK message")
97
+
98
+ log.debug("Starting handshake")
99
+
100
+ # Send the session type
101
+ await self.send_message(self._session_type.to_message(), force=True)
102
+ resp = await self.read_message()
103
+
104
+ # Authentication
105
+ # if the next message is an ACK, the server does not require authentication
106
+ # if it's a message with only a number, the server requires the open authentication algorithm
107
+ # if it's a ∗98∗## open command, the server requires the hmac authentication algorithm
108
+ if resp.type == MessageType.ACK:
109
+ log.info("No authentication required")
110
+ elif len(resp.tags) == 1:
111
+ log.info("Using open authentication")
112
+ await self._authenticate_open(nonce=resp.tags[0])
113
+ elif resp.tags[0] == "98":
114
+ log.info("Using hmac authentication")
115
+ tag = resp.tags[1]
116
+ await self._authenticate_hmac(
117
+ hash_algorithm=tag,
118
+ )
119
+ else:
120
+ raise InvalidAuthentication("Invalid authentication response")
121
+
122
+ log.info("Client ready")
123
+
124
+ async def _authenticate_open(self, nonce: str) -> None:
125
+ """
126
+ Authenticates the client using the open authentication algorithm
127
+
128
+ Args:
129
+ nonce (str): The nonce sent by the server
130
+ """
131
+ enc = own_calc_pass(self._password, nonce)
132
+
133
+ await self.send_message(GenericMessage(["#" + enc]), force=True)
134
+ resp = await self.read_message()
135
+
136
+ if resp.type != MessageType.ACK:
137
+ raise InvalidAuthentication("Invalid password")
138
+
139
+ async def _authenticate_hmac(self, hash_algorithm: AuthAlgorithm | str) -> None:
140
+ """
141
+ Authenticates the client using the hmac authentication algorithm
142
+
143
+ Args:
144
+ hash_algorithm (AuthAlgorithm | str): The hash algorithm to use
145
+ """
146
+ # TODO: Check with a real device if the handshake is implemented correctly
147
+ if isinstance(hash_algorithm, str):
148
+ try:
149
+ hash_algorithm = AuthAlgorithm.from_string(hash_algorithm)
150
+ except ValueError:
151
+ # Close the connection
152
+ await self.send_message(NACK(), force=True)
153
+ raise InvalidAuthentication("Invalid hash algorithm")
154
+
155
+ # Send an ACK to accept the algorithm and wait for the server key
156
+ await self.send_message(ACK(), force=True)
157
+ resp = await self.read_message()
158
+ server_key = resp.tags[0]
159
+
160
+ # Generate the client key
161
+ client_key = create_key(hash_algorithm)
162
+
163
+ # Generate the two authentication strings
164
+ client_auth = client_hmac(
165
+ server_key=server_key,
166
+ client_key=client_key,
167
+ password=self._password,
168
+ hash_algorithm=hash_algorithm
169
+ )
170
+ server_auth = server_hmac(
171
+ server_key=server_key,
172
+ client_key=client_key,
173
+ password=self._password,
174
+ hash_algorithm=hash_algorithm
175
+ )
176
+
177
+ # Send the client authentication string
178
+ await self.send_message(
179
+ GenericMessage([hex_to_digits(client_key), hex_to_digits(client_auth.hex())]),
180
+ force=True
181
+ )
182
+ resp = await self.read_message()
183
+
184
+ if resp.type == MessageType.NACK:
185
+ raise InvalidAuthentication("Invalid password")
186
+
187
+ # Check the server authentication string with the one generated
188
+ if not compare_hmac(
189
+ server_auth,
190
+ bytes.fromhex(hex_to_digits(resp.tags[0]))
191
+ ):
192
+ raise InvalidAuthentication("Invalid password")
193
+ else:
194
+ await self.send_message(ACK(), force=True)
195
+
196
+ async def send_message(self, message: BaseMessage, *, force: bool = False) -> None:
197
+ """
198
+ Sends a message to the server
199
+
200
+ Args:
201
+ message (BaseMessage): send to the server a subclass of BaseMessage
202
+ force (bool): if True, the message will be sent even if the client is set as an event session
203
+
204
+ Raises:
205
+ InvalidSession: if the client is set as an event session or if the client is not started
206
+ """
207
+ if not self.is_cmd_session() and not force:
208
+ raise InvalidSession("Cannot send messages in an event session")
209
+ if self._protocol is None:
210
+ raise InvalidSession("Client not started")
211
+
212
+ await self._protocol.send_message(message)
213
+
214
+ async def read_message(self, timeout: int | None = 5) -> BaseMessage:
215
+ """
216
+ Awaits a message from the server and returns it.
217
+
218
+ Returns:
219
+ BaseMessage: the message from the server, it will be a subclass of BaseMessage.
220
+
221
+ Raises:
222
+ TimeoutError: if the server does not respond in the given time.
223
+ InvalidSession: if the client is not started.
224
+ """
225
+ if self._protocol is None:
226
+ raise InvalidSession("Client not started")
227
+
228
+ async with asyncio.timeout(timeout):
229
+ message = await self._protocol.receive_messages()
230
+
231
+ return message
232
+
233
+ async def close(self) -> None:
234
+ """
235
+ Close the client
236
+ """
237
+ if self._transport is not None:
238
+ self._transport.close()
239
+
240
+ self._protocol = None
241
+ self._transport = None
242
+
243
+ @abstractmethod
244
+ async def loop(self):
245
+ """
246
+ Runs the client loop.
247
+ This is a loop that runs the entire event system for the client, it will read messages from the gateway and
248
+ dispatch them to the correct callbacks.
249
+ """
250
+ raise NotImplementedError
251
+
252
+ @staticmethod
253
+ def _exception_handler(_: AbstractEventLoop, context: dict[str, Any]):
254
+ exception = context.get("exception")
255
+ message = context.get("message")
256
+
257
+ if exception is not None:
258
+ log.error(f"Exception occurred: {message} ({exception})")
259
+ else:
260
+ return
261
+
262
+ if (task := context.get("task")) is not None:
263
+ task.print_stack()
264
+ elif (protocol := context.get("protocol")) is not None:
265
+ log.error(f"Protocol error: {protocol}")
266
+ elif (transport := context.get("transport")) is not None:
267
+ log.error(f"Transport error: {transport}")