ipguard-api 0.1.0b6__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.
@@ -0,0 +1,464 @@
1
+ """Message shapes established by the IPGUARD 3.4.8 Android package."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import time
7
+ from binascii import Error as Base64Error
8
+ from collections.abc import Mapping
9
+ from datetime import datetime
10
+ from unicodedata import category
11
+ from uuid import uuid4
12
+
13
+ from .errors import InvalidInputError, ProtocolError
14
+ from .models import (
15
+ AndroidProfile,
16
+ AuthIdentifier,
17
+ Board,
18
+ IncomingCall,
19
+ Relay,
20
+ Session,
21
+ SessionStatus,
22
+ )
23
+ from .validation import (
24
+ validate_identifier,
25
+ validate_installation_label,
26
+ validate_routing_identity,
27
+ validate_utf8,
28
+ validate_verification_code,
29
+ )
30
+
31
+ type Packet = dict[str, object]
32
+ MAX_MESSAGE_BYTES = 8 * 1024 * 1024
33
+ MAX_MESSAGE_FRAMES = 16
34
+ MAX_INVENTORY_SITES = 256
35
+ MAX_INVENTORY_BOARDS = 1024
36
+ MAX_CALL_TOKEN_BYTES = 1024
37
+ MAX_CALL_SDP_BYTES = 256 * 1024
38
+ MAX_CALL_METADATA_BYTES = 256
39
+
40
+
41
+ def envelope(
42
+ identity: str, event: str, body: Mapping[str, object], token: str | None = None
43
+ ) -> Packet:
44
+ return {
45
+ "header": {
46
+ "event": event,
47
+ "sender": identity,
48
+ "timestamp": time.time_ns() // 1_000_000,
49
+ },
50
+ "body": {**body, "token": str(uuid4()) if token is None else token},
51
+ }
52
+
53
+
54
+ def hangup_call(identity: str, token: str) -> Packet:
55
+ """Build the single supported hangup shape with the original call token."""
56
+ if not isinstance(token, str):
57
+ raise InvalidInputError("Invalid call token.")
58
+ packet = envelope(identity, "sp_brd_hangup_call", {"cause": 0}, token)
59
+ assert_android_metadata(packet)
60
+ return packet
61
+
62
+
63
+ def hello(identity: str, identifier: AuthIdentifier, session: Session | None) -> Packet:
64
+ # Fixed handset metadata also replaces obsolete saved implementation labels.
65
+ profile = AndroidProfile()
66
+ offset = datetime.now().astimezone().utcoffset()
67
+ data: dict[str, object] = {
68
+ "fcm_token": "",
69
+ "without_sip": True,
70
+ "os_version": profile.os_version,
71
+ "language": profile.language,
72
+ "timezone": int(offset.total_seconds()) if offset else 0,
73
+ "weektypes": [[], [], [], [], [], [], []],
74
+ "connectionInfo": profile.connection_info,
75
+ }
76
+ if session is not None:
77
+ label = validate_installation_label(session.label)
78
+ data.update(
79
+ {
80
+ "username": label if session.status is SessionStatus.ACTIVATED else "",
81
+ "phone": identifier.value if identifier.kind.value == "phone" else "",
82
+ "email": identifier.value if identifier.kind.value == "email" else "",
83
+ "akey": "",
84
+ "code": validate_verification_code(session.verification_code),
85
+ }
86
+ )
87
+ return envelope(
88
+ identity, "hello", {"type": 0, "version": profile.app_version, "data": data}
89
+ )
90
+
91
+
92
+ def identifier_fields(identifier: AuthIdentifier) -> dict[str, object]:
93
+ return {identifier.kind.value: identifier.value}
94
+
95
+
96
+ def packet_event(packet: Packet) -> str:
97
+ header = packet.get("header")
98
+ return str(header.get("event", "")) if isinstance(header, dict) else ""
99
+
100
+
101
+ def packet_body(packet: Packet) -> dict[str, object]:
102
+ body = packet.get("body")
103
+ return body if isinstance(body, dict) else {}
104
+
105
+
106
+ def packet_sender(packet: Packet) -> object:
107
+ header = packet.get("header")
108
+ return header.get("sender") if isinstance(header, dict) else None
109
+
110
+
111
+ def incoming_call(
112
+ packet: Packet, boards: Mapping[str, Board]
113
+ ) -> tuple[IncomingCall, bytes] | None:
114
+ """Validate an offer and return only its safe notification and token digest.
115
+
116
+ Authentication and current-transport checks belong to the caller. The SDP
117
+ is bounded, Base64-decoded and checked as UTF-8; no media session is created.
118
+ Malformed offers are ignored without raising credential-bearing errors.
119
+ """
120
+ if packet_event(packet) != "ps_sp_sdp_offer":
121
+ return None
122
+ body = packet_body(packet)
123
+ sender, remote, token, sdp = (
124
+ packet_sender(packet),
125
+ body.get("remoteIdentity"),
126
+ body.get("token"),
127
+ body.get("sdp"),
128
+ )
129
+ if not all(isinstance(value, str) for value in (sender, remote, token, sdp)):
130
+ return None
131
+ assert isinstance(sender, str) and isinstance(remote, str)
132
+ assert isinstance(token, str) and isinstance(sdp, str)
133
+ try:
134
+ validate_routing_identity(sender)
135
+ validate_routing_identity(remote)
136
+ encoded_token = validate_utf8(
137
+ token, MAX_CALL_TOKEN_BYTES, "Invalid call token."
138
+ )
139
+ if (
140
+ not sender.strip()
141
+ or not remote.strip()
142
+ or not token.strip()
143
+ or any(
144
+ ord(char) < 32 for value in (sender, remote, token) for char in value
145
+ )
146
+ or not sdp
147
+ or len(sdp) > MAX_CALL_SDP_BYTES
148
+ ):
149
+ return None
150
+ description = base64.b64decode(sdp.encode("ascii"), validate=True).decode(
151
+ "utf-8"
152
+ )
153
+ if (
154
+ not description
155
+ or "\0" in description
156
+ or description.splitlines()[0] != "v=0"
157
+ ):
158
+ return None
159
+ except InvalidInputError, UnicodeError, Base64Error:
160
+ return None
161
+ board = boards.get(remote)
162
+ if board is not None:
163
+ # Only the call projection is sanitized; release inventory is unchanged.
164
+ board = Board(
165
+ identity=board.identity,
166
+ name=_call_metadata(board.name),
167
+ site_id=_call_metadata(board.site_id) or None,
168
+ site_name=_call_metadata(board.site_name) or None,
169
+ )
170
+ return IncomingCall(board), hashlib.sha256(encoded_token).digest()
171
+
172
+
173
+ def _call_metadata(value: str | None) -> str:
174
+ if not isinstance(value, str) or not value.strip():
175
+ return ""
176
+ try:
177
+ validate_utf8(value, MAX_CALL_METADATA_BYTES, "Invalid call board metadata.")
178
+ except InvalidInputError:
179
+ return ""
180
+ if any(category(char) in {"Cc", "Cf"} for char in value):
181
+ return ""
182
+ return value
183
+
184
+
185
+ def assert_android_metadata(packet: Packet) -> None:
186
+ """Allow only supported envelopes and Android request shapes."""
187
+ header, body, profile = packet.get("header"), packet.get("body"), AndroidProfile()
188
+ if (
189
+ set(packet) != {"header", "body"}
190
+ or not isinstance(header, dict)
191
+ or set(header) != {"event", "sender", "timestamp"}
192
+ or not isinstance(body, dict)
193
+ or not isinstance(header.get("event"), str)
194
+ or not isinstance(header.get("sender"), str)
195
+ or type(header.get("timestamp")) is not int
196
+ or header["timestamp"] < 0
197
+ or not isinstance(body.get("token"), str)
198
+ or not body["token"]
199
+ ):
200
+ raise InvalidInputError("Unsupported Android message envelope.")
201
+ validate_routing_identity(header["sender"])
202
+ event = header["event"]
203
+ if event == "hello":
204
+ data = body.get("data")
205
+ basic = {
206
+ "fcm_token",
207
+ "without_sip",
208
+ "os_version",
209
+ "language",
210
+ "timezone",
211
+ "weektypes",
212
+ "connectionInfo",
213
+ }
214
+ authenticated = {"username", "phone", "email", "akey", "code"}
215
+ if (
216
+ set(body) != {"type", "version", "data", "token"}
217
+ or type(body.get("type")) is not int
218
+ or body["type"] != 0
219
+ or not isinstance(data, dict)
220
+ or set(data) not in (basic, basic | authenticated)
221
+ or body.get("version") != profile.app_version
222
+ ):
223
+ raise InvalidInputError("Incompatible Android greeting metadata.")
224
+ expected = {
225
+ "os_version": profile.os_version,
226
+ "language": profile.language,
227
+ "connectionInfo": profile.connection_info,
228
+ "fcm_token": "",
229
+ "without_sip": True,
230
+ "weektypes": [[], [], [], [], [], [], []],
231
+ }
232
+ if (
233
+ any(data.get(key) != value for key, value in expected.items())
234
+ or data.get("without_sip") is not True
235
+ or type(data.get("timezone")) is not int
236
+ or not -86400 <= data["timezone"] <= 86400
237
+ ):
238
+ raise InvalidInputError("Incompatible Android greeting metadata.")
239
+ if set(data) == basic | authenticated:
240
+ if (
241
+ not all(isinstance(data[key], str) for key in authenticated)
242
+ or data["akey"] != ""
243
+ or bool(data["phone"]) == bool(data["email"])
244
+ ):
245
+ raise InvalidInputError("Incompatible Android greeting metadata.")
246
+ if data["username"]:
247
+ validate_installation_label(data["username"])
248
+ validate_verification_code(data["code"])
249
+ _assert_identifier_fields(
250
+ {key: data[key] for key in ("phone", "email") if data[key]}
251
+ )
252
+ return
253
+ allowed = {
254
+ "sp_srv_check_auth": {"token"},
255
+ "sp_srv_validate_auth": {"token", "code"},
256
+ "sp_srv_ask_key": {"token", "code"},
257
+ "sp_srv_check_username": {"token", "code", "username", "model"},
258
+ "sp_srv_override_username": {"token", "code", "username", "model"},
259
+ "sp_srv_update_ack": {"token"},
260
+ "sp_brd_open_door": {"token", "door"},
261
+ "sp_brd_hangup_call": {"token", "cause"},
262
+ }
263
+ expected_keys = allowed.get(event)
264
+ if expected_keys is None:
265
+ raise InvalidInputError("Unsupported Android message event.")
266
+ if event.startswith("sp_srv_") and event != "sp_srv_update_ack":
267
+ identifiers = {key: body[key] for key in ("phone", "email") if key in body}
268
+ _assert_identifier_fields(identifiers)
269
+ expected_keys = expected_keys | set(identifiers)
270
+ if set(body) != expected_keys:
271
+ raise InvalidInputError("Unsupported Android message fields.")
272
+ if "code" in body:
273
+ validate_verification_code(body["code"])
274
+ if event in ("sp_srv_check_username", "sp_srv_override_username"):
275
+ label = body.get("username")
276
+ if not isinstance(label, str) or body.get("model") != profile.model:
277
+ raise InvalidInputError("Incompatible Android registration metadata.")
278
+ validate_installation_label(label)
279
+ if event == "sp_brd_open_door" and (
280
+ type(body["door"]) is not int or not 1 <= body["door"] <= 3
281
+ ):
282
+ raise InvalidInputError("Unsupported Android relay number.")
283
+ if event == "sp_brd_hangup_call":
284
+ token = body["token"]
285
+ assert isinstance(token, str)
286
+ validate_utf8(token, MAX_CALL_TOKEN_BYTES, "Invalid call token.")
287
+ if (
288
+ type(body["cause"]) is not int
289
+ or body["cause"] != 0
290
+ or not token.strip()
291
+ or any(ord(char) < 32 for char in token)
292
+ ):
293
+ raise InvalidInputError("Unsupported Android hangup message.")
294
+
295
+
296
+ def _assert_identifier_fields(fields: Mapping[str, object]) -> None:
297
+ if len(fields) != 1:
298
+ raise InvalidInputError("Unsupported Android account fields.")
299
+ kind, value = next(iter(fields.items()))
300
+ if not isinstance(value, str):
301
+ raise InvalidInputError("Unsupported Android account fields.")
302
+ identifier = validate_identifier(value)
303
+ if identifier.kind.value != kind or identifier.value != value:
304
+ raise InvalidInputError("Unsupported Android account fields.")
305
+
306
+
307
+ def decode_frames(frames: list[bytes]) -> Packet | None:
308
+ if len(frames) > MAX_MESSAGE_FRAMES or sum(map(len, frames)) > MAX_MESSAGE_BYTES:
309
+ raise ProtocolError("Service message exceeds supported bounds.")
310
+ for frame in reversed(frames):
311
+ try:
312
+ value = json.loads(frame)
313
+ if isinstance(value, str):
314
+ value = json.loads(value)
315
+ except UnicodeDecodeError, ValueError:
316
+ continue
317
+ except RecursionError:
318
+ raise ProtocolError(
319
+ "Service message nesting exceeds supported bounds."
320
+ ) from None
321
+ if not isinstance(value, dict):
322
+ continue
323
+ if value.get("action") == "register":
324
+ continue
325
+ header, body = value.get("header"), value.get("body")
326
+ if (
327
+ isinstance(header, dict)
328
+ and isinstance(header.get("event"), str)
329
+ and isinstance(body, dict)
330
+ ):
331
+ return value
332
+ # SIP action/register controls and unrelated/noisy frames are not auth replies.
333
+ return None
334
+
335
+
336
+ def _decoded(value: object) -> object:
337
+ if isinstance(value, str):
338
+ try:
339
+ return json.loads(value)
340
+ except ValueError, RecursionError:
341
+ raise ProtocolError("Invalid service data encoding.") from None
342
+ return value
343
+
344
+
345
+ def configuration_password(packet: Packet) -> str | None:
346
+ data = _decoded(packet_body(packet).get("data"))
347
+ item: object = None
348
+ if isinstance(data, list):
349
+ item = data[1] if len(data) > 1 else None
350
+ elif isinstance(data, dict):
351
+ item = data.get("1")
352
+ elif data is not None:
353
+ raise ProtocolError("Unsupported account configuration shape.")
354
+ password = item.get("sip_pwd") if isinstance(item, dict) else None
355
+ return password if isinstance(password, str) and password else None
356
+
357
+
358
+ def relay_inventory(body: Mapping[str, object]) -> tuple[Relay, ...]:
359
+ """Return relay permissions, preserving the existing discovery contract."""
360
+ return door_inventory(body)[1]
361
+
362
+
363
+ def door_inventory(
364
+ body: Mapping[str, object],
365
+ ) -> tuple[tuple[Board, ...], tuple[Relay, ...]]:
366
+ """Read the inventory once, including boards with no release permissions."""
367
+ source = _decoded(body.get("boards") if "boards" in body else body.get("sites"))
368
+ if isinstance(source, list):
369
+ sites: dict[str, object] = {"0": {"name": "", "boards": source}}
370
+ elif isinstance(source, dict):
371
+ sites = source
372
+ else:
373
+ raise ProtocolError(
374
+ "Door inventory has no supported boards or sites structure."
375
+ )
376
+ relays: list[Relay] = []
377
+ boards: list[Board] = []
378
+ seen: set[tuple[str, int]] = set()
379
+ if len(sites) > MAX_INVENTORY_SITES:
380
+ raise ProtocolError("Door inventory exceeds supported bounds.")
381
+ board_count = 0
382
+ for site_id, site in sites.items():
383
+ if not isinstance(site, dict) or not isinstance(site.get("boards"), list):
384
+ continue
385
+ board_count += len(site["boards"])
386
+ if board_count > MAX_INVENTORY_BOARDS:
387
+ raise ProtocolError("Door inventory exceeds supported bounds.")
388
+ for item in site["boards"]:
389
+ if (
390
+ not isinstance(item, dict)
391
+ or not isinstance(item.get("identity"), str)
392
+ or not item["identity"]
393
+ ):
394
+ continue
395
+ try:
396
+ validate_routing_identity(item["identity"])
397
+ except InvalidInputError:
398
+ raise ProtocolError("Invalid service board identity.") from None
399
+ board = Board(
400
+ identity=item["identity"],
401
+ name=_name(item.get("name")),
402
+ site_id=str(site_id),
403
+ site_name=_name(site.get("name")),
404
+ device_id=_scalar(item.get("device_id")),
405
+ device_type=_scalar(item.get("device_type")),
406
+ )
407
+ boards.append(board)
408
+ access, names = item.get("relay_access_mobile_v2"), item.get("relay_name")
409
+ if not isinstance(access, list):
410
+ continue
411
+ names = names if isinstance(names, list) else []
412
+ pending = bool(item.get("waitConfirmBadgeDigital"))
413
+ for index, permission in enumerate(access[:3]):
414
+ if not isinstance(permission, dict):
415
+ continue
416
+ identity = (board.identity, index + 1)
417
+ if identity in seen:
418
+ raise ProtocolError("Ambiguous board relay inventory.")
419
+ seen.add(identity)
420
+ kind = _scalar(permission.get("type"))
421
+ relays.append(
422
+ Relay(
423
+ board=board,
424
+ number=index + 1,
425
+ name=_name(names[index])
426
+ if index < len(names)
427
+ else f"Relay {index + 1}",
428
+ remote_allowed=not pending and kind in (0, 2, "0", "2"),
429
+ pending_confirmation=pending,
430
+ access_type=kind,
431
+ access_mode=_scalar(permission.get("mode")),
432
+ )
433
+ )
434
+ return tuple(boards), tuple(relays)
435
+
436
+
437
+ def _name(value: object) -> str:
438
+ return value if isinstance(value, str) else ""
439
+
440
+
441
+ def _scalar(value: object) -> int | str | None:
442
+ return (
443
+ value if isinstance(value, (int, str)) and not isinstance(value, bool) else None
444
+ )
445
+
446
+
447
+ def validate_endpoint(endpoint: str) -> None:
448
+ from urllib.parse import urlsplit
449
+
450
+ try:
451
+ value = urlsplit(endpoint)
452
+ valid = (
453
+ value.scheme == "tcp"
454
+ and value.hostname
455
+ and value.port
456
+ and not value.path
457
+ and not value.query
458
+ and not value.fragment
459
+ and not value.username
460
+ )
461
+ except ValueError:
462
+ valid = False
463
+ if not valid:
464
+ raise InvalidInputError("Expected a ZeroMQ TCP service endpoint.")
ipguard_api/py.typed ADDED
File without changes