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>
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""Web Push protocol: RFC 8291 encryption (against the RFC's worked example and
|
|
by decrypting as a browser would), VAPID signatures, the push-service allow-list."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
|
|
from app.push import webpush
|
|
from app.push.webpush import b64u_decode, b64u_encode
|
|
|
|
# RFC 8291 §5 / Appendix A.
|
|
RFC_AUTH = "BTBZMqHH6r4Tts7J_aSIgg"
|
|
RFC_UA_PUBLIC = (
|
|
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
|
|
)
|
|
RFC_AS_PRIVATE = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
|
|
RFC_SALT = "DGv6ra1nlYgDCS1FRnbzlw"
|
|
RFC_BODY = (
|
|
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml"
|
|
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT"
|
|
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
|
|
)
|
|
|
|
|
|
def _hkdf(salt: bytes, ikm: bytes, info: bytes, n: int) -> bytes:
|
|
return HKDF(algorithm=hashes.SHA256(), length=n, salt=salt, info=info).derive(ikm)
|
|
|
|
|
|
def browser_decrypt(body: bytes, ua: ec.EllipticCurvePrivateKey, auth: bytes) -> bytes:
|
|
"""What the browser does with a push body (RFC 8291 receiver side)."""
|
|
salt, idlen = body[:16], body[20]
|
|
as_public = body[21 : 21 + idlen]
|
|
ua_public = ua.public_key().public_bytes(
|
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
|
)
|
|
as_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)
|
|
ikm = _hkdf(
|
|
auth, ua.exchange(ec.ECDH(), as_key), b"WebPush: info\x00" + ua_public + as_public, 32
|
|
)
|
|
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
|
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
|
plain = AESGCM(cek).decrypt(nonce, body[21 + idlen :], None)
|
|
assert plain.endswith(b"\x02")
|
|
return plain[:-1]
|
|
|
|
|
|
def test_rfc8291_worked_example() -> None:
|
|
sender = ec.derive_private_key(
|
|
int.from_bytes(b64u_decode(RFC_AS_PRIVATE), "big"), ec.SECP256R1()
|
|
)
|
|
body = webpush.encrypt(
|
|
b"When I grow up, I want to be a watermelon",
|
|
RFC_UA_PUBLIC,
|
|
RFC_AUTH,
|
|
salt=b64u_decode(RFC_SALT),
|
|
sender=sender,
|
|
)
|
|
assert b64u_encode(body) == RFC_BODY
|
|
|
|
|
|
def test_browser_can_decrypt() -> None:
|
|
ua = ec.generate_private_key(ec.SECP256R1())
|
|
auth = b"0123456789abcdef"
|
|
p256dh = b64u_encode(
|
|
ua.public_key().public_bytes(
|
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
|
)
|
|
)
|
|
payload = json.dumps({"title": "Zażółć gęślą jaźń"}).encode()
|
|
body = webpush.encrypt(payload, p256dh, b64u_encode(auth))
|
|
assert browser_decrypt(body, ua, auth) == payload
|
|
|
|
|
|
def test_vapid_authorization_is_a_valid_es256_jwt() -> None:
|
|
key = webpush.Vapid.generate()
|
|
again = webpush.Vapid.from_pem(key.pem())
|
|
assert again.public_b64 == key.public_b64
|
|
header = key.authorization("https://fcm.googleapis.com/fcm/send/abc", "https://ultimo.email")
|
|
token = header.split("t=")[1].split(",")[0]
|
|
assert header.endswith(f"k={key.public_b64}")
|
|
head, claims, sig = token.split(".")
|
|
assert json.loads(b64u_decode(claims)) | {"exp": 0} == {
|
|
"aud": "https://fcm.googleapis.com",
|
|
"sub": "https://ultimo.email",
|
|
"exp": 0,
|
|
}
|
|
raw = b64u_decode(sig)
|
|
der = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
|
|
public = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
ec.SECP256R1(), b64u_decode(key.public_b64)
|
|
)
|
|
public.verify(der, f"{head}.{claims}".encode(), ec.ECDSA(hashes.SHA256())) # raises if bad
|
|
|
|
|
|
def test_only_push_services_are_allowed() -> None:
|
|
ok = [
|
|
"https://fcm.googleapis.com/fcm/send/x",
|
|
"https://jmt17.google.com/fcm/send/x",
|
|
"https://updates.push.services.mozilla.com/wpush/v2/x",
|
|
"https://web.push.apple.com/x",
|
|
"https://wns2-db5p.notify.windows.com/w/?token=x",
|
|
]
|
|
bad = [
|
|
"http://fcm.googleapis.com/fcm/send/x",
|
|
"https://fcm.googleapis.com:8443/x",
|
|
"https://evil.com/fcm.googleapis.com",
|
|
"https://fcm.googleapis.com.evil.com/x",
|
|
"https://google.com.evil.com/x",
|
|
"https://notapple-push.apple.com.evil.com/x",
|
|
"https://127.0.0.1/x",
|
|
"https://postgres/x",
|
|
]
|
|
assert all(webpush.allowed_endpoint(u) for u in ok)
|
|
assert not any(webpush.allowed_endpoint(u) for u in bad)
|