pycupra 0.0.14__py3-none-any.whl → 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.
@@ -0,0 +1,519 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import secrets
8
+ import time
9
+ import uuid
10
+ from base64 import b64encode, urlsafe_b64encode
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable
13
+
14
+ from aiohttp import ClientSession, ClientTimeout
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric import ec
17
+ from google.protobuf.json_format import MessageToDict, MessageToJson
18
+
19
+ from .const import (
20
+ AUTH_VERSION,
21
+ FCM_INSTALLATION,
22
+ FCM_REGISTRATION,
23
+ FCM_SEND_URL,
24
+ GCM_CHECKIN_URL,
25
+ GCM_REGISTER_URL,
26
+ GCM_SERVER_KEY_B64,
27
+ SDK_VERSION,
28
+ )
29
+ from .proto.android_checkin_pb2 import (
30
+ DEVICE_CHROME_BROWSER,
31
+ AndroidCheckinProto,
32
+ ChromeBuildProto,
33
+ )
34
+ from .proto.checkin_pb2 import (
35
+ AndroidCheckinRequest,
36
+ AndroidCheckinResponse,
37
+ )
38
+
39
+ _logger = logging.getLogger(__name__)
40
+
41
+
42
+ @dataclass
43
+ class FcmRegisterConfig:
44
+ project_id: str
45
+ app_id: str
46
+ api_key: str
47
+ messaging_sender_id: str
48
+ bundle_id: str = "receiver.push.com"
49
+ chrome_id: str = "org.chromium.linux"
50
+ chrome_version: str = "94.0.4606.51"
51
+ vapid_key: str | None = GCM_SERVER_KEY_B64
52
+ persistend_ids: list[str] | None = None
53
+ heartbeat_interval_ms: int = 5 * 60 * 1000 # 5 mins
54
+
55
+ def __postinit__(self) -> None:
56
+ if self.persistend_ids is None:
57
+ self.persistend_ids = []
58
+
59
+
60
+ class FcmRegister:
61
+ CLIENT_TIMEOUT = ClientTimeout(total=3)
62
+
63
+ def __init__(
64
+ self,
65
+ config: FcmRegisterConfig,
66
+ credentials: dict | None = None,
67
+ credentials_updated_callback: Callable[[dict[str, Any]], None] | None = None,
68
+ *,
69
+ http_client_session: ClientSession | None = None,
70
+ log_debug_verbose: bool = False,
71
+ ):
72
+ self.config = config
73
+ self.credentials = credentials
74
+ self.credentials_updated_callback = credentials_updated_callback
75
+
76
+ self._log_debug_verbose = log_debug_verbose
77
+
78
+ self._http_client_session = http_client_session
79
+ self._local_session: ClientSession | None = None
80
+
81
+ def _get_checkin_payload(
82
+ self, android_id: int | None = None, security_token: int | None = None
83
+ ) -> AndroidCheckinRequest:
84
+ chrome = ChromeBuildProto()
85
+ chrome.platform = ChromeBuildProto.Platform.PLATFORM_LINUX # 3
86
+ chrome.chrome_version = self.config.chrome_version
87
+ chrome.channel = ChromeBuildProto.Channel.CHANNEL_STABLE # 1
88
+
89
+ checkin = AndroidCheckinProto()
90
+ checkin.type = DEVICE_CHROME_BROWSER # 3
91
+ checkin.chrome_build.CopyFrom(chrome)
92
+
93
+ payload = AndroidCheckinRequest()
94
+ payload.user_serial_number = 0
95
+ payload.checkin.CopyFrom(checkin)
96
+ payload.version = 3
97
+ if android_id and security_token:
98
+ payload.id = int(android_id)
99
+ payload.security_token = int(security_token)
100
+
101
+ return payload
102
+
103
+ async def gcm_check_in_and_register(
104
+ self,
105
+ ) -> dict[str, Any] | None:
106
+ options = await self.gcm_check_in()
107
+ if not options:
108
+ raise RuntimeError("Unable to register and check in to gcm")
109
+ gcm_credentials = await self.gcm_register(options)
110
+ return gcm_credentials
111
+
112
+ async def gcm_check_in(
113
+ self,
114
+ android_id: int | None = None,
115
+ security_token: int | None = None,
116
+ ) -> dict[str, Any] | None:
117
+ """
118
+ perform check-in request
119
+
120
+ android_id, security_token can be provided if we already did the initial
121
+ check-in
122
+
123
+ returns dict with android_id, security_token and more
124
+ """
125
+
126
+ payload = self._get_checkin_payload(android_id, security_token)
127
+
128
+ if self._log_debug_verbose:
129
+ _logger.debug("GCM check in payload:\n%s", payload)
130
+
131
+ retries = 3
132
+ acir = None
133
+ content = None
134
+ for try_num in range(retries):
135
+ try:
136
+ async with self._session.post(
137
+ url=GCM_CHECKIN_URL,
138
+ headers={"Content-Type": "application/x-protobuf"},
139
+ data=payload.SerializeToString(),
140
+ timeout=self.CLIENT_TIMEOUT,
141
+ ) as resp:
142
+ if resp.status == 200:
143
+ acir = AndroidCheckinResponse()
144
+ content = await resp.read()
145
+ break
146
+ else:
147
+ text = await resp.text()
148
+ if acir and content:
149
+ break
150
+ else:
151
+ _logger.warning(
152
+ "GCM checkin failed on attempt %s out "
153
+ + "of %s with status: %s, %s",
154
+ try_num + 1,
155
+ retries,
156
+ resp.status,
157
+ text,
158
+ )
159
+ # retry without android id and security_token
160
+ payload = self._get_checkin_payload()
161
+ await asyncio.sleep(1)
162
+ except Exception as e:
163
+ _logger.warning(
164
+ "Error during gcm checkin post attempt %s out of %s",
165
+ try_num + 1,
166
+ retries,
167
+ exc_info=e,
168
+ )
169
+ await asyncio.sleep(1)
170
+
171
+ if not acir or not content:
172
+ _logger.error("Unable to checkin to gcm after %s retries", retries)
173
+ return None
174
+ acir.ParseFromString(content)
175
+
176
+ if self._log_debug_verbose:
177
+ msg = MessageToJson(acir, indent=4)
178
+ _logger.debug("GCM check in response (raw):\n%s", msg)
179
+
180
+ return MessageToDict(acir)
181
+
182
+ async def gcm_register(
183
+ self,
184
+ options: dict[str, Any],
185
+ retries: int = 4,
186
+ ) -> dict[str, str] | None:
187
+ """
188
+ obtains a gcm token
189
+
190
+ app_id: app id as an integer
191
+ retries: number of failed requests before giving up
192
+
193
+ returns {"token": "...", "gcm_app_id": 123123, "androidId":123123,
194
+ "securityToken": 123123}
195
+ """
196
+ # contains android_id, security_token and more
197
+ gcm_app_id = f"wp:{self.config.bundle_id}#{uuid.uuid4()}"
198
+ android_id = options["androidId"]
199
+ security_token = options["securityToken"]
200
+
201
+ headers = {
202
+ "Authorization": f"AidLogin {android_id}:{security_token}",
203
+ "Content-Type": "application/x-www-form-urlencoded",
204
+ }
205
+ body = {
206
+ "app": "org.chromium.linux",
207
+ "X-subtype": gcm_app_id,
208
+ "device": android_id,
209
+ "sender": GCM_SERVER_KEY_B64,
210
+ }
211
+ if self._log_debug_verbose:
212
+ _logger.debug("GCM Registration request: %s", body)
213
+
214
+ last_error: str | Exception | None = None
215
+ for try_num in range(retries):
216
+ try:
217
+ async with self._session.post(
218
+ url=GCM_REGISTER_URL,
219
+ headers=headers,
220
+ data=body,
221
+ timeout=self.CLIENT_TIMEOUT,
222
+ ) as resp:
223
+ response_text = await resp.text()
224
+ if "Error" in response_text:
225
+ _logger.warning(
226
+ "GCM register request attempt %s out of %s has failed with %s",
227
+ try_num + 1,
228
+ retries,
229
+ response_text,
230
+ )
231
+ last_error = response_text
232
+ await asyncio.sleep(1)
233
+ continue
234
+ token = response_text.split("=")[1]
235
+
236
+ return {
237
+ "token": token,
238
+ "app_id": gcm_app_id,
239
+ "android_id": android_id,
240
+ "security_token": security_token,
241
+ }
242
+
243
+ except Exception as e:
244
+ last_error = e
245
+ _logger.warning(
246
+ "Error during gcm auth request attempt %s out of %s",
247
+ try_num + 1,
248
+ retries,
249
+ exc_info=e,
250
+ )
251
+ await asyncio.sleep(1)
252
+
253
+ errorstr = f"Unable to complete gcm auth request after {retries} tries"
254
+ if isinstance(last_error, Exception):
255
+ _logger.error(errorstr, exc_info=last_error)
256
+ else:
257
+ errorstr += f", last error was {last_error}"
258
+ _logger.error(errorstr)
259
+ return None
260
+
261
+ async def fcm_install_and_register(
262
+ self, gcm_data: dict[str, Any], keys: dict[str, Any]
263
+ ) -> dict[str, Any] | None:
264
+ if installation := await self.fcm_install():
265
+ registration = await self.fcm_register(gcm_data, installation, keys)
266
+ return {
267
+ "registration": registration,
268
+ "installation": installation,
269
+ }
270
+ return None
271
+
272
+ async def fcm_install(self) -> dict | None:
273
+ fid = bytearray(secrets.token_bytes(17))
274
+ # Replace the first 4 bits with the FID header 0b0111.
275
+ fid[0] = 0b01110000 + (fid[0] % 0b00010000)
276
+ fid64 = b64encode(fid).decode()
277
+
278
+ hb_header = b64encode(
279
+ json.dumps({"heartbeats": [], "version": 2}).encode()
280
+ ).decode()
281
+ headers = {
282
+ "x-firebase-client": hb_header,
283
+ "x-goog-api-key": self.config.api_key,
284
+ }
285
+ payload = {
286
+ "appId": self.config.app_id,
287
+ "authVersion": AUTH_VERSION,
288
+ "fid": fid64,
289
+ "sdkVersion": SDK_VERSION,
290
+ }
291
+ url = FCM_INSTALLATION + f"projects/{self.config.project_id}/installations"
292
+ async with self._session.post(
293
+ url=url,
294
+ headers=headers,
295
+ data=json.dumps(payload),
296
+ timeout=self.CLIENT_TIMEOUT,
297
+ ) as resp:
298
+ if resp.status == 200:
299
+ fcm_install = await resp.json()
300
+
301
+ return {
302
+ "token": fcm_install["authToken"]["token"],
303
+ "expires_in": int(fcm_install["authToken"]["expiresIn"][:-1:]),
304
+ "refresh_token": fcm_install["refreshToken"],
305
+ "fid": fcm_install["fid"],
306
+ "created_at": time.monotonic(),
307
+ }
308
+ else:
309
+ text = await resp.text()
310
+ _logger.error(
311
+ "Error during fcm_install: %s ",
312
+ text,
313
+ )
314
+ return None
315
+
316
+ async def fcm_refresh_install_token(self) -> dict | None:
317
+ hb_header = b64encode(
318
+ json.dumps({"heartbeats": [], "version": 2}).encode()
319
+ ).decode()
320
+ if not self.credentials:
321
+ raise RuntimeError("Credentials must be set to refresh install token")
322
+ fcm_refresh_token = self.credentials["fcm"]["installation"]["refresh_token"]
323
+
324
+ headers = {
325
+ "Authorization": f"{AUTH_VERSION} {fcm_refresh_token}",
326
+ "x-firebase-client": hb_header,
327
+ "x-goog-api-key": self.config.api_key,
328
+ }
329
+ payload = {
330
+ "installation": {
331
+ "sdkVersion": SDK_VERSION,
332
+ "appId": self.config.app_id,
333
+ }
334
+ }
335
+ url = (
336
+ FCM_INSTALLATION + f"projects/{self.config.project_id}/"
337
+ "installations/{fid}/authTokens:generate"
338
+ )
339
+ async with self._session.post(
340
+ url=url,
341
+ headers=headers,
342
+ data=json.dumps(payload),
343
+ timeout=self.CLIENT_TIMEOUT,
344
+ ) as resp:
345
+ if resp.status == 200:
346
+ fcm_refresh = await resp.json()
347
+ return {
348
+ "token": fcm_refresh["token"],
349
+ "expires_in": int(fcm_refresh["expiresIn"][:-1:]),
350
+ "created_at": time.monotonic(),
351
+ }
352
+ else:
353
+ text = await resp.text()
354
+ _logger.error(
355
+ "Error during fcm_refresh_install_token: %s ",
356
+ text,
357
+ )
358
+ return None
359
+
360
+ def generate_keys(self) -> dict:
361
+ private_key = ec.generate_private_key(ec.SECP256R1())
362
+ public_key = private_key.public_key()
363
+
364
+ serialized_private = private_key.private_bytes(
365
+ encoding=serialization.Encoding.DER, # asn1
366
+ format=serialization.PrivateFormat.PKCS8,
367
+ encryption_algorithm=serialization.NoEncryption(),
368
+ )
369
+ serialized_public = public_key.public_bytes(
370
+ encoding=serialization.Encoding.DER,
371
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
372
+ )
373
+
374
+ return {
375
+ "public": urlsafe_b64encode(serialized_public[26:]).decode(
376
+ "ascii"
377
+ ), # urlsafe_base64(serialized_public[26:]),
378
+ "private": urlsafe_b64encode(serialized_private).decode("ascii"),
379
+ "secret": urlsafe_b64encode(os.urandom(16)).decode("ascii"),
380
+ }
381
+
382
+ async def fcm_register(
383
+ self,
384
+ gcm_data: dict,
385
+ installation: dict,
386
+ keys: dict,
387
+ retries: int = 4,
388
+ ) -> dict[str, Any] | None:
389
+ headers = {
390
+ "x-goog-api-key": self.config.api_key,
391
+ "x-goog-firebase-installations-auth": installation["token"],
392
+ }
393
+ # If vapid_key is the default do not send it here or it will error
394
+ vapid_key = (
395
+ self.config.vapid_key
396
+ if self.config.vapid_key != GCM_SERVER_KEY_B64
397
+ else None
398
+ )
399
+ payload = {
400
+ "web": {
401
+ "applicationPubKey": vapid_key,
402
+ "auth": keys["secret"],
403
+ "endpoint": FCM_SEND_URL + gcm_data["token"],
404
+ "p256dh": keys["public"],
405
+ }
406
+ }
407
+ url = FCM_REGISTRATION + f"projects/{self.config.project_id}/registrations"
408
+ if self._log_debug_verbose:
409
+ _logger.debug("FCM registration data: %s", payload)
410
+
411
+ for try_num in range(retries):
412
+ try:
413
+ async with self._session.post(
414
+ url=url,
415
+ headers=headers,
416
+ data=json.dumps(payload),
417
+ timeout=self.CLIENT_TIMEOUT,
418
+ ) as resp:
419
+ if resp.status == 200:
420
+ fcm = await resp.json()
421
+ return fcm
422
+ else:
423
+ text = await resp.text()
424
+ _logger.error( # pylint: disable=duplicate-code
425
+ "Error during fmc register request "
426
+ "attempt %s out of %s: %s",
427
+ try_num + 1,
428
+ retries,
429
+ text,
430
+ )
431
+
432
+ except Exception as e:
433
+ _logger.error( # pylint: disable=duplicate-code
434
+ "Error during fmc register request attempt %s out of %s",
435
+ try_num + 1,
436
+ retries,
437
+ exc_info=e,
438
+ )
439
+ await asyncio.sleep(1)
440
+ return None
441
+
442
+ async def checkin_or_register(self, fcmCredentialsFileName) -> dict[str, Any]:
443
+ """Check in if you have credentials otherwise register as a new client.
444
+
445
+ :param sender_id: sender id identifying push service you are connecting to.
446
+ :param app_id: identifier for your application.
447
+ :return: The FCM token which is used to identify you with the push end
448
+ point application.
449
+ """
450
+ if self.credentials:
451
+ gcm_response = await self.gcm_check_in(
452
+ self.credentials["gcm"]["android_id"],
453
+ self.credentials["gcm"]["security_token"],
454
+ )
455
+ if gcm_response:
456
+ return self.credentials
457
+
458
+ self.credentials = await self.register()
459
+ if self.credentials_updated_callback:
460
+ await self.credentials_updated_callback(self.credentials, fcmCredentialsFileName)
461
+
462
+ return self.credentials
463
+
464
+ async def register(self) -> dict:
465
+ """Register gcm and fcm tokens for sender_id.
466
+ Typically you would
467
+ call checkin instead of register which does not do a full registration
468
+ if credentials are present
469
+
470
+ :param sender_id: sender id identifying push service you are connecting to.
471
+ :param app_id: identifier for your application.
472
+ :return: The dict containing all credentials.
473
+ """
474
+
475
+ keys = self.generate_keys()
476
+
477
+ gcm_data = await self.gcm_check_in_and_register()
478
+ if gcm_data is None:
479
+ raise RuntimeError(
480
+ "Unable to establish subscription with Google Cloud Messaging."
481
+ )
482
+ self._log_verbose("GCM subscription: %s", gcm_data)
483
+
484
+ fcm_data = await self.fcm_install_and_register(gcm_data, keys)
485
+ if not fcm_data:
486
+ raise RuntimeError("Unable to register with fcm")
487
+ self._log_verbose("FCM registration: %s", fcm_data)
488
+ res: dict[str, Any] = {
489
+ "keys": keys,
490
+ "gcm": gcm_data,
491
+ "fcm": fcm_data,
492
+ "config": {
493
+ "bundle_id": self.config.bundle_id,
494
+ "project_id": self.config.project_id,
495
+ "vapid_key": self.config.vapid_key,
496
+ },
497
+ }
498
+ self._log_verbose("Credential: %s", res)
499
+ _logger.info("Registered with FCM")
500
+ return res
501
+
502
+ def _log_verbose(self, msg: str, *args: object) -> None:
503
+ if self._log_debug_verbose:
504
+ _logger.debug(msg, *args)
505
+
506
+ @property
507
+ def _session(self) -> ClientSession:
508
+ if self._http_client_session:
509
+ return self._http_client_session
510
+ if self._local_session is None:
511
+ self._local_session = ClientSession()
512
+ return self._local_session
513
+
514
+ async def close(self) -> None:
515
+ """Close aiohttp session."""
516
+ session = self._local_session
517
+ self._local_session = None
518
+ if session:
519
+ await session.close()
File without changes