|
|
|
@@ -0,0 +1,401 @@
|
|
|
|
|
"""Web Push: a notification that arrives with nothing of ours running.
|
|
|
|
|
|
|
|
|
|
Everything else here is polled. `/api/chats/unread` runs in an open page, which
|
|
|
|
|
is enough for "a reply landed while you were on another chat" and is nothing at
|
|
|
|
|
all for the case this exists for -- a schedule firing at 07:00 on a laptop whose
|
|
|
|
|
browser is shut. There is no way to close that gap from inside a page, because
|
|
|
|
|
there is no page.
|
|
|
|
|
|
|
|
|
|
## The trade, stated plainly
|
|
|
|
|
|
|
|
|
|
A push goes to the **browser vendor's** push service: Google's for Chrome,
|
|
|
|
|
Mozilla's for Firefox, Apple's for Safari. The endpoint is chosen by the
|
|
|
|
|
browser and there is no version of this feature that avoids it. That sits
|
|
|
|
|
against "a self-hosted tool must not report page views to a third party", and
|
|
|
|
|
the answer is not that the tension is imaginary:
|
|
|
|
|
|
|
|
|
|
- The payload is encrypted end to end (RFC 8291) with a key derived from a
|
|
|
|
|
secret only the browser and this server hold, so the push service carries
|
|
|
|
|
bytes it cannot read.
|
|
|
|
|
- What it *does* learn is that this server sent something to that subscription,
|
|
|
|
|
and when. On a personal instance that is a timing channel over your own
|
|
|
|
|
activity, and it is real.
|
|
|
|
|
- So it is **opt-in per device**, off until somebody presses the button, and
|
|
|
|
|
the rest of the notification system works without it.
|
|
|
|
|
|
|
|
|
|
Nothing else in LLeMbas contacts an outside service on its own.
|
|
|
|
|
|
|
|
|
|
## Hand-rolled, and why
|
|
|
|
|
|
|
|
|
|
`pywebpush` would do this in three lines and bring `http-ece` and `py-vapid`
|
|
|
|
|
with it. The encryption below is one ECDH, two HKDFs and one AES-GCM seal, all
|
|
|
|
|
from `cryptography`, which is already a dependency because API keys are
|
|
|
|
|
Fernet-encrypted. That is the same call the MCP client makes: a hand-written
|
|
|
|
|
client, so what actually goes on the wire is in this repository.
|
|
|
|
|
|
|
|
|
|
## The two specifications
|
|
|
|
|
|
|
|
|
|
**RFC 8291** is the payload: `aes128gcm`, one record, the salt and the server's
|
|
|
|
|
ephemeral public key carried in the body's own header block.
|
|
|
|
|
|
|
|
|
|
**RFC 8292** is the authorisation: a JWT signed with a P-256 key whose public
|
|
|
|
|
half identifies this server. The keypair is generated once and kept in the
|
|
|
|
|
settings table with the private half Fernet-encrypted. It must be **stable** --
|
|
|
|
|
the public key is baked into every subscription a browser has made, so
|
|
|
|
|
regenerating it silently invalidates all of them.
|
|
|
|
|
|
|
|
|
|
## Failure is a subscription being dropped, not an error
|
|
|
|
|
|
|
|
|
|
A push service answers 404 or 410 for a subscription that no longer exists --
|
|
|
|
|
the browser was uninstalled, the site's data cleared, the permission revoked.
|
|
|
|
|
That is the normal end of a subscription's life and not a fault, so those two
|
|
|
|
|
delete the row. Everything else is logged and left, because a push service
|
|
|
|
|
having a bad hour is not a reason to lose somebody's registration.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import time
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from hashlib import sha256
|
|
|
|
|
from typing import Any
|
|
|
|
|
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 import utils as asym_utils
|
|
|
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
|
|
|
|
|
|
from lembas.db.models import PushSubscription, User
|
|
|
|
|
from lembas.services import settings_store
|
|
|
|
|
from lembas.services.crypto import decrypt, encrypt
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# How long a push service should hold an undelivered message. Four hours: long
|
|
|
|
|
# enough that a laptop opened after lunch still gets the morning's report, short
|
|
|
|
|
# enough that nothing arrives claiming to be news when it is a day old.
|
|
|
|
|
TTL_SECONDS = 4 * 3600
|
|
|
|
|
|
|
|
|
|
# The JWT's life. Twelve hours is the maximum RFC 8292 allows, and a short one
|
|
|
|
|
# buys nothing here -- it is minted per request.
|
|
|
|
|
JWT_SECONDS = 12 * 3600
|
|
|
|
|
|
|
|
|
|
# Bigger than any payload we send; the record size field must still be present
|
|
|
|
|
# and must exceed the ciphertext.
|
|
|
|
|
RECORD_SIZE = 4096
|
|
|
|
|
|
|
|
|
|
# What a payload may carry. A push service will refuse a large body outright,
|
|
|
|
|
# and there is nothing here worth more than a title and a line.
|
|
|
|
|
MAX_PAYLOAD_BYTES = 3000
|
|
|
|
|
|
|
|
|
|
SETTING_PRIVATE = "push_private_key"
|
|
|
|
|
SETTING_PUBLIC = "push_public_key"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- base64url, without padding, everywhere ------------------------------------
|
|
|
|
|
def b64(raw: bytes) -> str:
|
|
|
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unb64(text: str) -> bytes:
|
|
|
|
|
padded = text + "=" * (-len(text) % 4)
|
|
|
|
|
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- The server's identity -----------------------------------------------------
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Keys:
|
|
|
|
|
private: ec.EllipticCurvePrivateKey
|
|
|
|
|
public_b64: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def keys(db: DBSession) -> Keys:
|
|
|
|
|
"""This instance's VAPID keypair, generated once and kept.
|
|
|
|
|
|
|
|
|
|
Generated on first use rather than by a setup step, because a feature that
|
|
|
|
|
needs somebody to run a command before it works is one that is off on every
|
|
|
|
|
instance that did not read the release notes. The private half is Fernet-
|
|
|
|
|
encrypted at rest, like every other secret here.
|
|
|
|
|
|
|
|
|
|
**Never regenerated.** The public key is inside every subscription a browser
|
|
|
|
|
holds, so a new one silently invalidates all of them -- notifications simply
|
|
|
|
|
stop, with nothing anywhere saying why.
|
|
|
|
|
"""
|
|
|
|
|
stored = settings_store.get(db, SETTING_PRIVATE)
|
|
|
|
|
if stored:
|
|
|
|
|
private = serialization.load_pem_private_key(decrypt(str(stored)).encode(), password=None)
|
|
|
|
|
return Keys(private=private, public_b64=str(settings_store.get(db, SETTING_PUBLIC) or ""))
|
|
|
|
|
|
|
|
|
|
private = ec.generate_private_key(ec.SECP256R1())
|
|
|
|
|
pem = private.private_bytes(
|
|
|
|
|
encoding=serialization.Encoding.PEM,
|
|
|
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
|
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
|
|
|
).decode()
|
|
|
|
|
public_b64 = b64(_raw_public(private.public_key()))
|
|
|
|
|
settings_store.update(db, {SETTING_PRIVATE: encrypt(pem), SETTING_PUBLIC: public_b64})
|
|
|
|
|
log.info("generated a VAPID keypair for web push")
|
|
|
|
|
return Keys(private=private, public_b64=public_b64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def public_key(db: DBSession) -> str:
|
|
|
|
|
"""What a browser needs in order to subscribe."""
|
|
|
|
|
return keys(db).public_b64
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raw_public(key: ec.EllipticCurvePublicKey) -> bytes:
|
|
|
|
|
"""The uncompressed 65-byte point, which is the only form either spec uses."""
|
|
|
|
|
return key.public_bytes(
|
|
|
|
|
encoding=serialization.Encoding.X962,
|
|
|
|
|
format=serialization.PublicFormat.UncompressedPoint,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- RFC 8292: proving who is asking -------------------------------------------
|
|
|
|
|
def _jwt(private: ec.EllipticCurvePrivateKey, audience: str, subject: str) -> str:
|
|
|
|
|
header = b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
|
|
|
|
|
claims = b64(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{"aud": audience, "exp": int(time.time()) + JWT_SECONDS, "sub": subject},
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
).encode()
|
|
|
|
|
)
|
|
|
|
|
signing_input = f"{header}.{claims}".encode()
|
|
|
|
|
|
|
|
|
|
der = private.sign(signing_input, ec.ECDSA(hashes.SHA256()))
|
|
|
|
|
# JWS wants the raw pair, not DER. `cryptography` only signs to DER, so it
|
|
|
|
|
# is decoded and re-emitted fixed-width -- a leading zero dropped here is a
|
|
|
|
|
# signature every push service rejects, and the error it gives is 401.
|
|
|
|
|
r, s = asym_utils.decode_dss_signature(der)
|
|
|
|
|
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
|
|
|
|
return f"{header}.{claims}.{b64(raw)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _audience(endpoint: str) -> str:
|
|
|
|
|
parts = urlsplit(endpoint)
|
|
|
|
|
return f"{parts.scheme}://{parts.netloc}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- RFC 8291: the payload ------------------------------------------------------
|
|
|
|
|
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
|
|
|
|
|
"""Extract-then-expand, written out because the two halves take different
|
|
|
|
|
salts here and `HKDF` in one call cannot express that."""
|
|
|
|
|
prk = hmac.new(salt, ikm, sha256).digest()
|
|
|
|
|
return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(prk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encrypt_payload(payload: bytes, *, p256dh: str, auth: str) -> bytes:
|
|
|
|
|
"""One `aes128gcm` record, ready to be the body of the POST.
|
|
|
|
|
|
|
|
|
|
The layout is the specification's, and the order matters to a parser that
|
|
|
|
|
has never seen our code:
|
|
|
|
|
|
|
|
|
|
salt (16) | record size (4) | key id length (1) | server key (65) | ct
|
|
|
|
|
"""
|
|
|
|
|
client_public = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
|
|
|
ec.SECP256R1(), unb64(p256dh)
|
|
|
|
|
)
|
|
|
|
|
auth_secret = unb64(auth)
|
|
|
|
|
|
|
|
|
|
server_private = ec.generate_private_key(ec.SECP256R1())
|
|
|
|
|
server_public = _raw_public(server_private.public_key())
|
|
|
|
|
shared = server_private.exchange(ec.ECDH(), client_public)
|
|
|
|
|
|
|
|
|
|
# The first HKDF is salted with the subscription's own auth secret and its
|
|
|
|
|
# info binds both public keys, which is what stops a captured record being
|
|
|
|
|
# replayed at a different subscriber.
|
|
|
|
|
key_info = b"WebPush: info\x00" + unb64(p256dh) + server_public
|
|
|
|
|
ikm = _hkdf(auth_secret, shared, key_info, 32)
|
|
|
|
|
|
|
|
|
|
salt = os.urandom(16)
|
|
|
|
|
content_key = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
|
|
|
|
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
|
|
|
|
|
|
|
|
|
# 0x02 is the padding delimiter for the last (here, only) record. 0x01 would
|
|
|
|
|
# say another follows, and a receiver would wait for it.
|
|
|
|
|
ciphertext = AESGCM(content_key).encrypt(nonce, payload + b"\x02", None)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
salt
|
|
|
|
|
+ RECORD_SIZE.to_bytes(4, "big")
|
|
|
|
|
+ len(server_public).to_bytes(1, "big")
|
|
|
|
|
+ server_public
|
|
|
|
|
+ ciphertext
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Sending --------------------------------------------------------------------
|
|
|
|
|
def subject_for(db: DBSession) -> str:
|
|
|
|
|
"""The `sub` claim: who to contact about this server's pushes.
|
|
|
|
|
|
|
|
|
|
A URL is as acceptable as a mailto and needs nothing configured, so the
|
|
|
|
|
instance's own base URL is used when there is one. Push services require the
|
|
|
|
|
claim to be present; none of them checks that it resolves.
|
|
|
|
|
"""
|
|
|
|
|
configured = str(settings_store.get(db, "public_url") or "").strip()
|
|
|
|
|
return configured or "https://lembas.invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_one(db: DBSession, subscription: PushSubscription, payload: dict[str, Any]) -> bool:
|
|
|
|
|
"""Deliver to one registration. True if it was accepted.
|
|
|
|
|
|
|
|
|
|
Never raises: this runs from arrival paths that must not fail because a push
|
|
|
|
|
service is having a bad hour.
|
|
|
|
|
"""
|
|
|
|
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
|
|
|
|
if len(body) > MAX_PAYLOAD_BYTES: # pragma: no cover - titles are bounded already
|
|
|
|
|
body = json.dumps({"title": payload.get("title", "LLeMbas")}).encode()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
encrypted = encrypt_payload(
|
|
|
|
|
body, p256dh=subscription.p256dh, auth=subscription.auth_secret
|
|
|
|
|
)
|
|
|
|
|
token = _jwt(keys(db).private, _audience(subscription.endpoint), subject_for(db))
|
|
|
|
|
except Exception: # noqa: BLE001 - a malformed stored key must not kill a reply
|
|
|
|
|
log.exception("could not build a push for %s", subscription.id)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
"TTL": str(TTL_SECONDS),
|
|
|
|
|
"Content-Encoding": "aes128gcm",
|
|
|
|
|
"Content-Type": "application/octet-stream",
|
|
|
|
|
# "high" would let a phone wake for it; this is news, not an alarm.
|
|
|
|
|
"Urgency": "normal",
|
|
|
|
|
"Authorization": f"vapid t={token}, k={keys(db).public_b64}",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
|
|
|
response = await client.post(subscription.endpoint, content=encrypted, headers=headers)
|
|
|
|
|
except httpx.RequestError as exc:
|
|
|
|
|
log.warning("push to %s failed: %s", _audience(subscription.endpoint), exc)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if response.status_code in (404, 410):
|
|
|
|
|
# The normal end of a subscription's life: uninstalled, cleared, or the
|
|
|
|
|
# permission revoked. Deleting it is the correct response and not an
|
|
|
|
|
# error -- keeping it would mean retrying forever against a dead address.
|
|
|
|
|
log.info("push subscription %s is gone; removing it", subscription.id)
|
|
|
|
|
db.delete(subscription)
|
|
|
|
|
db.commit()
|
|
|
|
|
return False
|
|
|
|
|
if response.status_code >= 400:
|
|
|
|
|
subscription.last_error = f"{response.status_code}: {response.text[:200]}"
|
|
|
|
|
db.commit()
|
|
|
|
|
log.warning(
|
|
|
|
|
"push to %s refused: %s %s",
|
|
|
|
|
_audience(subscription.endpoint),
|
|
|
|
|
response.status_code,
|
|
|
|
|
response.text[:200],
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if subscription.last_error:
|
|
|
|
|
subscription.last_error = ""
|
|
|
|
|
db.commit()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def subscriptions_for(db: DBSession, user: User | None) -> list[PushSubscription]:
|
|
|
|
|
if user is None:
|
|
|
|
|
return []
|
|
|
|
|
return list(
|
|
|
|
|
db.scalars(select(PushSubscription).where(PushSubscription.user_id == user.id))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def announce(
|
|
|
|
|
db: DBSession, user: User | None, *, title: str, body: str, url: str = "", kind: str = ""
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Tell every device this person has registered. Returns how many took it.
|
|
|
|
|
|
|
|
|
|
Called at the moment something arrives rather than from the poll, because
|
|
|
|
|
the whole point is the case where no page is open to poll. A device with a
|
|
|
|
|
page open gets this *and* the in-page toast -- the service worker resolves
|
|
|
|
|
that by not showing a notification when one of its own windows is focused,
|
|
|
|
|
which is the only place that can be known.
|
|
|
|
|
"""
|
|
|
|
|
subscriptions = subscriptions_for(db, user)
|
|
|
|
|
if not subscriptions:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
payload = {"title": title, "body": body, "url": url, "kind": kind}
|
|
|
|
|
delivered = 0
|
|
|
|
|
for subscription in list(subscriptions):
|
|
|
|
|
if await send_one(db, subscription, payload):
|
|
|
|
|
delivered += 1
|
|
|
|
|
return delivered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Fire-and-forget tasks, held so the event loop does not collect one mid-flight.
|
|
|
|
|
# asyncio keeps only a weak reference to a task nobody awaits, and a push that
|
|
|
|
|
# vanishes halfway is the kind of intermittent nobody reproduces.
|
|
|
|
|
_TASKS: set[Any] = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def announce_later(user_id: str, *, title: str, body: str, url: str = "", kind: str = "") -> None:
|
|
|
|
|
"""Announce from a path that must not wait for it, and must not fail with it.
|
|
|
|
|
|
|
|
|
|
Called where something *arrives* -- a reply finishing with nobody watching,
|
|
|
|
|
a report being filed, a run posting into Messages -- rather than from the
|
|
|
|
|
poll. That is the whole point: the poll needs an open page, and the case
|
|
|
|
|
worth a notification is the one where there is none.
|
|
|
|
|
|
|
|
|
|
Each of those sites runs exactly once per arrival, which is what makes this
|
|
|
|
|
fire once with no "already notified" flag of its own. `unread_notified` is
|
|
|
|
|
the *page's* record of having toasted; borrowing it here would mean whichever
|
|
|
|
|
channel got there first silenced the other.
|
|
|
|
|
|
|
|
|
|
Its own session, opened inside the task: the caller's is usually about to be
|
|
|
|
|
committed and closed, and holding one open across a POST to somebody else's
|
|
|
|
|
server is how a request comes to wait on a push service having a bad day.
|
|
|
|
|
"""
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
from lembas.db.session import session_scope
|
|
|
|
|
|
|
|
|
|
# The loop is checked *before* the coroutine is built, not by catching what
|
|
|
|
|
# `create_task` raises without one. A coroutine made and then dropped is a
|
|
|
|
|
# "never awaited" RuntimeWarning from wherever it was created -- which here
|
|
|
|
|
# is every synchronous caller in the suite and every CLI command that files
|
|
|
|
|
# a report. The warning would be the only symptom, and it would be
|
|
|
|
|
# attributed to the caller rather than to this.
|
|
|
|
|
try:
|
|
|
|
|
asyncio.get_running_loop()
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
async def run() -> None:
|
|
|
|
|
try:
|
|
|
|
|
with session_scope() as db:
|
|
|
|
|
user = db.get(User, user_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
return
|
|
|
|
|
await announce(db, user, title=title, body=body, url=url, kind=kind)
|
|
|
|
|
except Exception: # noqa: BLE001 - nothing upstream can act on this
|
|
|
|
|
log.exception("could not announce to %s", user_id)
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(run())
|
|
|
|
|
_TASKS.add(task)
|
|
|
|
|
task.add_done_callback(_TASKS.discard)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
"announce",
|
|
|
|
|
"announce_later",
|
|
|
|
|
"encrypt_payload",
|
|
|
|
|
"keys",
|
|
|
|
|
"public_key",
|
|
|
|
|
"send_one",
|
|
|
|
|
"subscriptions_for",
|
|
|
|
|
]
|