"""Web Push (VAPID) — key management and the low-level send. This is the transport under the notification engine: notify.py decides *what* to say and *to whom*; this module signs and delivers it to a browser push service. Keys are resolved once, in this order (mirrors the self-signed-cert flow in run.sh — generated on first use, reused thereafter): 1. env — ``THERMOGRAPH_VAPID_PRIVATE_KEY`` + ``THERMOGRAPH_VAPID_PUBLIC_KEY``, both base64url-raw (the private key is the 32-byte scalar, the public key the 65-byte uncompressed point — the standard VAPID interchange format). Set both in production so every worker/deploy signs with the same identity. 2. ``data/vapid.json`` — a gitignored keypair written on first run. 3. freshly generated, then written to (2). The private key signs the VAPID JWT; the public key is the ``applicationServerKey`` the browser needs at subscribe time (served via GET /push/vapid-key). Nothing here touches the database — ``send()`` returns a status string and the caller (which owns the session) prunes rows the push service says are gone. """ import base64 import json import logging import os import threading import audit from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from pywebpush import WebPushException, webpush log = logging.getLogger("thermograph.push") _DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data")) _VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json") # The VAPID "sub" claim — a contact the push service can reach about our traffic. _CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app") _lock = threading.Lock() _keys = None # cached {"private_key": str, "public_key": str} (base64url-raw) def _b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() def _generate() -> dict: """A fresh P-256 keypair as base64url-raw: 32-byte private scalar + 65-byte uncompressed public point (the format pywebpush's from_string and the browser applicationServerKey both accept).""" priv = ec.generate_private_key(ec.SECP256R1()) scalar = priv.private_numbers().private_value.to_bytes(32, "big") raw_pub = priv.public_key().public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint, ) return {"private_key": _b64url(scalar), "public_key": _b64url(raw_pub)} def _load() -> dict: """Resolve the keypair once (env → file → generate) and cache it.""" global _keys if _keys is not None: return _keys with _lock: if _keys is not None: return _keys env_priv = os.environ.get("THERMOGRAPH_VAPID_PRIVATE_KEY") env_pub = os.environ.get("THERMOGRAPH_VAPID_PUBLIC_KEY") if env_priv and env_pub: _keys = {"private_key": env_priv.strip(), "public_key": env_pub.strip()} return _keys try: with open(_VAPID_PATH, encoding="utf-8") as f: data = json.load(f) if data.get("private_key") and data.get("public_key"): _keys = {"private_key": data["private_key"], "public_key": data["public_key"]} return _keys except (OSError, ValueError): pass _keys = _generate() try: os.makedirs(_DATA_DIR, exist_ok=True) with open(_VAPID_PATH, "w", encoding="utf-8") as f: json.dump(_keys, f) os.chmod(_VAPID_PATH, 0o600) except OSError: log.warning("could not persist VAPID keys to %s; using an in-memory pair", _VAPID_PATH) return _keys def public_key() -> str: """The applicationServerKey (base64url raw point) the browser subscribes with.""" return _load()["public_key"] def send(subscription_info: dict, payload: dict) -> str: """Deliver one push. Returns 'ok', 'gone' (prune the row), or 'error'. `subscription_info` is the browser shape: {endpoint, keys:{p256dh, auth}}. Never raises — a bad send must not break the caller's transaction. """ keys = _load() try: webpush( subscription_info=subscription_info, data=json.dumps(payload), vapid_private_key=keys["private_key"], vapid_claims={"sub": _CONTACT}, ttl=86400, ) return "ok" except WebPushException as e: status = getattr(getattr(e, "response", None), "status_code", None) if status in (404, 410): return "gone" # endpoint retired — caller should delete it # 401/403 = VAPID key mismatch, etc. Record it where it's visible (the errors # JSONL / dashboard), not just the system journal. log.warning("web push failed (status=%s): %s", status, e) audit.log_event("error", {"phase": "push", "status": status, "endpoint": (subscription_info.get("endpoint") or "")[:120]}) return "error" except Exception as e: # noqa: BLE001 - network/encoding errors must not propagate log.warning("web push send raised", exc_info=True) audit.log_event("error", {"phase": "push", "error": repr(e), "endpoint": (subscription_info.get("endpoint") or "")[:120]}) return "error"