Files
LLeMbas/src/lembas/services/push.py
T
Homer 96f269dadb Boundaries that were supposed to hold
The security pass. Six findings, none reachable by visiting the site and
every one a boundary this codebase says it keeps.

A subagent is pinned to a list of read-only commands, in every mode,
unattended, with no card anybody could approve -- and `find *` was on it.
find writes files with -fprintf, runs programs with -exec and removes them
with -delete, and none of that needs a character the metacharacter guard
refuses. A page the model had just read could ask for a helper and get a
key into authorized_keys, from Plan mode, which promises to change
nothing. Refused in `subject()` rather than trimmed from the list: a
pattern cannot say "and no dangerous flags", and "this one looks
read-only" is exactly what put find there.

The loopback guard missed `0.0.0.0`, which is not is_loopback but does
connect to localhost -- so it answered a *decided* False and skipped the
DNS half too. The one spelling of "this machine" that walked past a guard
whose whole job is that sentence.

Twice in the update helper, which is the one place this deliberately
crosses a privilege boundary: root ran a script the service account owns,
and root sourced a file that account can replace. Either turns a
compromise of the web application into root. The first needed no
compromise at all -- a pull happens as the service user and root runs
whatever it fetched, so control of the branch was control of root. The
old test asserted that exact ExecStart line and had pinned it in place.

Push endpoints skipped check_url, the only outbound request that did. And
a chat could be filed in another account's folder, which hands over its
system prompt -- `_new_chat` resolved the folder, discarded it when it was
not the caller's, and stored the raw id anyway.

An existing helper install keeps the old wiring until install.sh is
re-run; update.sh now says so when it finds itself inside the checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:45:59 +02:00

416 lines
16 KiB
Python

"""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 fetch as fetch_service
from lembas.services import settings_store
from lembas.services.crypto import decrypt, encrypt
from lembas.services.fetch import FetchError
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}",
}
# Again, on a stored value. The subscribe route checks it too, but the row
# outlives that check: a name that pointed at a push service when it was
# registered can point inside the network later, and this is the side that
# actually opens the socket. The same split `agent/hosts.py` makes.
try:
fetch_service.check_url(subscription.endpoint)
except FetchError as exc:
log.warning(
"refusing to push to %s: %s", _audience(subscription.endpoint), exc.message
)
return False
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",
]