Coverage for app/backend/src/couchers/notifications/web_push_api.py: 78%
40 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import logging
2from time import time
3from typing import Any
4from urllib.parse import urlparse
6import http_ece
7import requests
8from cryptography.hazmat.primitives import serialization
9from cryptography.hazmat.primitives.asymmetric import ec
10from py_vapid import Vapid
12from couchers.crypto import b64decode_unpadded, b64encode_unpadded
14logger = logging.getLogger(__name__)
17def gen_vapid_keys() -> tuple[str, str]:
18 prv_key = ec.generate_private_key(ec.SECP256R1())
19 pub_key = prv_key.public_key()
20 prv = prv_key.private_numbers().private_value.to_bytes(length=32)
21 pub = pub_key.public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
22 return b64encode_unpadded(prv), b64encode_unpadded(pub)
25def get_vapid_public_key_from_private_key(private: str) -> str:
26 pub = Vapid.from_string(private).public_key
27 result: str = b64encode_unpadded(
28 pub.public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
29 )
30 return result
33def generate_vapid_authorization(endpoint: str, vapid_sub: str, vapid_private_key: str) -> str:
34 url = urlparse(endpoint)
35 vapid_claim = {
36 "sub": vapid_sub,
37 "aud": f"{url.scheme}://{url.netloc}",
38 "exp": int(time()) + (12 * 60 * 60),
39 }
40 return Vapid.from_string(private_key=vapid_private_key).sign(vapid_claim)["Authorization"] # type: ignore[no-any-return]
43def send_web_push(
44 data: bytes,
45 endpoint: str,
46 auth_key: bytes,
47 receiver_key: bytes,
48 vapid_sub: str,
49 vapid_private_key: str,
50 ttl: int = 0,
51) -> requests.Response:
52 logger.debug(f"Sending {len(data)} bytes to {endpoint[:20]}...")
53 headers = {
54 "authorization": generate_vapid_authorization(endpoint, vapid_sub, vapid_private_key),
55 "content-encoding": "aes128gcm",
56 "ttl": str(ttl),
57 # WNS (Windows Notification Service, the push backend for Edge) 400s with
58 # "Ttl value conflicts with X-WNS-Cache-Policy" unless this matches the ttl
59 "x-wns-cache-policy": "no-cache" if ttl == 0 else "cache",
60 }
62 encrypted = http_ece.encrypt(
63 data,
64 private_key=ec.generate_private_key(ec.SECP256R1()),
65 auth_secret=auth_key,
66 dh=receiver_key,
67 )
69 return requests.post(
70 endpoint,
71 timeout=20,
72 data=encrypted,
73 headers=headers,
74 )
77def debug_response_headers(resp: requests.Response) -> dict[str, str] | None:
78 """Pick out the response headers worth reporting when a push fails.
80 WNS reports why it rejected a push in x-wns-* headers and leaves the body empty.
81 """
82 headers = {k: v for k, v in resp.headers.items() if k.lower().startswith("x-wns-")}
83 return headers or None
86def decode_key(value: str) -> bytes:
87 return b64decode_unpadded(value.encode())
90def parse_subscription_info(subscription_info: dict[str, Any]) -> tuple[str, bytes, bytes]:
91 endpoint = subscription_info["endpoint"]
92 auth_key = decode_key(subscription_info["keys"]["auth"])
93 receiver_key = decode_key(subscription_info["keys"]["p256dh"])
94 return endpoint, auth_key, receiver_key