96f269dadb
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>
416 lines
16 KiB
Python
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",
|
|
]
|