Files
webmail-client/backend/app/push/webpush.py
T
lukasandClaude Opus 5.5 fda4cc6f16 Installable app, push notifications about new mail, Install app entry
The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar
badge), theme-coloured title bar following the chosen theme, and an app-icon
badge with the unread Inbox count. The service worker is always installed;
offline access is on only when it is registered as /sw.js?offline=1, so
turning it on or off is an update that applies at once.

Notifications (Settings → Accounts → Notifications, per device) use Web Push.
Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in
app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and
checked against the RFC's worked example. The server makes its key pair on
first use (private half encrypted with MASTER_KEY). New mail found by the live
sync in an Inbox enqueues a job 5 s later that tells the user's devices about
messages still unread there, under 2 hours old and not from the user; more
than three become one "N new messages". A subscription belongs to the session
it was made under: signed out or revoked, nothing more is sent there. Endpoints
are limited to the known push services (no SSRF). Nothing shows while the app
is in front (except on Apple, which requires it). Tested with real pushes
through Google's service in Chromium and Opera.

The mobile drawer gets "Install app": Chrome's own install dialog where the
browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with
"Don't show this again". docs/nas-install.md §5.3 covers installing,
notifications, Public URL and a later Android app (TWA).

Migration 0022: push_subscriptions, app_settings.vapid_*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:52:26 +02:00

194 lines
7.1 KiB
Python

"""Web Push, the protocol (RFC 8030 delivery, RFC 8291 encryption, RFC 8292 VAPID).
A browser that wants notifications gives us a push service URL (Google's,
Mozilla's, Apple's, Microsoft's) and two keys. A message is encrypted to those
keys, so the push service only carries it, and signed with our own key pair
(VAPID), so the push service knows it comes from the server the browser
subscribed to. Written here rather than taken from pywebpush, which needs a
newer ``cryptography`` than the rest of the backend pins; it is small, and
``tests/push`` checks it against the RFC 8291 worked example.
"""
from __future__ import annotations
import base64
import json
import os
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
RECORD_SIZE = 4096
# Push services only: the endpoint comes from the browser, i.e. from the user,
# and the server posts to it — anything else would let a user aim the server at
# an internal address (SSRF). Every browser in use delivers through one of these.
PUSH_HOSTS = (
"fcm.googleapis.com", # Chrome, Edge on Android, Opera, Samsung Internet
"android.googleapis.com",
".google.com", # Chromium builds subscribe at jmt17.google.com and the like
".push.services.mozilla.com", # Firefox
".push.apple.com", # Safari, and installed apps on iPhone/iPad
".notify.windows.com", # Edge on Windows
)
def b64u_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def b64u_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def allowed_endpoint(endpoint: str) -> bool:
"""Whether ``endpoint`` is an https URL of a known push service."""
parts = urlsplit(endpoint)
host = (parts.hostname or "").lower()
if parts.scheme != "https" or not host or parts.port not in (None, 443):
return False
return any(host == h or (h.startswith(".") and host.endswith(h)) for h in PUSH_HOSTS)
def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
return key.public_key().public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, info=info).derive(ikm)
def encrypt(
payload: bytes,
p256dh: str,
auth: str,
*,
salt: bytes | None = None,
sender: ec.EllipticCurvePrivateKey | None = None,
) -> bytes:
"""``payload`` encrypted for one browser (RFC 8291, ``aes128gcm``), as the
request body: header (salt, record size, our one-off public key) + one
record. ``salt`` and ``sender`` are for the RFC test vector only."""
ua_public = b64u_decode(p256dh)
auth_secret = b64u_decode(auth)
sender = sender or ec.generate_private_key(ec.SECP256R1())
as_public = _public_bytes(sender)
ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
ecdh_secret = sender.exchange(ec.ECDH(), ua_key)
ikm = _hkdf(auth_secret, ecdh_secret, b"WebPush: info\x00" + ua_public + as_public, 32)
salt = salt or os.urandom(16)
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
# One record, no padding: 0x02 marks the last (only) record.
record = AESGCM(cek).encrypt(nonce, payload + b"\x02", None)
header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public
return header + record
# ---- VAPID (RFC 8292) ---------------------------------------------------------------
@dataclass(frozen=True)
class Vapid:
"""The server's signing key pair."""
private: ec.EllipticCurvePrivateKey
@classmethod
def generate(cls) -> Vapid:
return cls(ec.generate_private_key(ec.SECP256R1()))
@classmethod
def from_pem(cls, pem: bytes) -> Vapid:
key = serialization.load_pem_private_key(pem, password=None)
if not isinstance(key, ec.EllipticCurvePrivateKey):
raise ValueError("VAPID key is not an EC key")
return cls(key)
def pem(self) -> bytes:
return self.private.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
@property
def public_b64(self) -> str:
"""The public key as browsers take it (``applicationServerKey``)."""
return b64u_encode(_public_bytes(self.private))
def authorization(self, endpoint: str, subject: str, *, ttl: int = 12 * 3600) -> str:
"""The ``Authorization`` header for a push to ``endpoint``: a JWT (ES256)
naming the push service's origin, with our public key."""
parts = urlsplit(endpoint)
claims = {
"aud": f"{parts.scheme}://{parts.netloc}",
"exp": int(time.time()) + ttl,
"sub": subject,
}
header = {"typ": "JWT", "alg": "ES256"}
signing_input = (
b64u_encode(json.dumps(header, separators=(",", ":")).encode())
+ "."
+ b64u_encode(json.dumps(claims, separators=(",", ":")).encode())
)
der = self.private.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return f"vapid t={signing_input}.{b64u_encode(signature)}, k={self.public_b64}"
# ---- delivery (RFC 8030) ------------------------------------------------------------
class Gone(Exception):
"""The subscription no longer exists (404/410): forget it."""
class Failed(Exception):
"""The push service refused or could not be reached; worth trying later."""
async def send(
client: httpx.AsyncClient,
*,
endpoint: str,
p256dh: str,
auth: str,
payload: dict[str, object],
vapid: Vapid,
subject: str,
ttl: int = 24 * 3600,
urgency: str = "normal",
topic: str | None = None,
) -> None:
"""Deliver one encrypted message; raise :class:`Gone` or :class:`Failed`."""
if not allowed_endpoint(endpoint):
raise Gone("not a push service")
body = encrypt(json.dumps(payload, separators=(",", ":")).encode(), p256dh, auth)
headers = {
"Authorization": vapid.authorization(endpoint, subject),
"Content-Encoding": "aes128gcm",
"Content-Type": "application/octet-stream",
"TTL": str(ttl),
"Urgency": urgency,
}
if topic:
headers["Topic"] = topic # replaces an undelivered message with the same topic
try:
resp = await client.post(endpoint, content=body, headers=headers)
except httpx.HTTPError as err:
raise Failed(str(err)) from err
if resp.status_code in (404, 410):
raise Gone(f"HTTP {resp.status_code}")
if resp.status_code >= 400:
raise Failed(f"HTTP {resp.status_code}: {resp.text[:200]}")