diff --git a/.env.example b/.env.example index 4f62699..8b44076 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ PORT=8000 # SENTRY_DSN=https://public@sentry.example/1 # SENTRY_ENVIRONMENT=development +# Optional. MCP Events sealing keys: comma-separated :, each a JWE key for the mcp-subscription+jwe envelope. The first one +# seals and every one opens. Rotate by adding a new key in front and dropping +# the old one after the longest subscription TTL (24h) has passed. +# Generate a key with `openssl rand -base64 32`. +# MCP_EVENTS_SEALING_KEYS=k1: + # ── Integration tests only (not used by the running server) ───────────────── # The live integration suite authenticates to the Appwrite API with an API key # and runs against APPWRITE_PROJECT_ID (above). diff --git a/docs/development.md b/docs/development.md index 1de762e..86db250 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,6 +37,13 @@ docker compose up --build > To enable hosted HTTP error monitoring locally, set `SENTRY_DSN`. You can also > set `SENTRY_ENVIRONMENT`; Compose defaults it to `development`. +> MCP Events seals each subscription into its Appwrite webhook as a +> [`mcp-subscription+jwe`](https://github.com/ChiragAgg5k/mcp-event-subscriptions/blob/main/profiles/encrypted-envelope.md) +> envelope, with the keys in `MCP_EVENTS_SEALING_KEYS` (`: random bytes>`, comma separated, first one active). Generate a key with +> `openssl rand -base64 32`. To rotate, put a new key first and remove the old +> one once the longest subscription TTL (24h) has passed. + **`uv` directly — HTTP:** ```bash diff --git a/pyproject.toml b/pyproject.toml index 4fad3c5..e7b81f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "starlette>=0.40.0", "uvicorn[standard]>=0.30.0", "httpx>=0.27.0", + "cryptography>=44.0.0", + "mcp-event-subscriptions>=0.1.0a1,<0.2", "pyjwt[crypto]>=2.9.0", "numpy>=2.0", "openai>=1.40", diff --git a/src/mcp_server_appwrite/events/envelope.py b/src/mcp_server_appwrite/events/envelope.py new file mode 100644 index 0000000..f111301 --- /dev/null +++ b/src/mcp_server_appwrite/events/envelope.py @@ -0,0 +1,536 @@ +"""Sealed subscription envelopes for MCP Events. + +The hosted server keeps no subscription store. Each subscription is one Appwrite +project webhook, and everything the ingress needs to deliver an event is sealed +into that webhook's ``authPassword``, which Appwrite stores encrypted, never +returns, and sends back as HTTP Basic auth on every delivery: + +* The webhook ``$id`` is the deterministic :func:`subscription_id`, so a refresh + is an upsert and an unsubscribe is a delete by id. +* ``authPassword`` holds the envelope from :meth:`Keyring.seal`: a Subscription + Record v1 sealed with the ``mcp-subscription+jwe`` profile of + `mcp-event-subscriptions `_ + (JWE Compact, ``dir`` + ``A256GCM``). The envelope is bound to a + :class:`Context`: this server (``MCP_PUBLIC_URL``), the project as tenant and + the webhook id as resource, so an envelope copied onto another webhook, + project or deployment fails to open. Appwrite only sends Basic auth when + ``authUsername`` is non-empty too, so the webhook needs a fixed, non-secret + username alongside it. +* The webhook ``secret`` is :meth:`Keyring.signing_key`, derived from the + sealing key and the subscription id, so the ingress can check + ``X-Appwrite-Webhook-Signature`` (:func:`verify_appwrite_signature`) without + storage. + +This module is the adapter around the package: it builds and reads the record, +supplies the expected context from trusted configuration only, and keeps what +the profile leaves to adapters (subscription ids, the principal digest, the +Appwrite signing key and the ``authPassword`` size budget). + +Sealing keys come from ``MCP_EVENTS_SEALING_KEYS`` (``:``, +comma separated, first one active). Every key in the ring opens envelopes, so a +rotation adds the new key in front and drops the old one once every subscription +has refreshed (the TTL cap bounds how long that takes). +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from types import MappingProxyType +from typing import Any + +import mcp_event_subscriptions as profile +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +KEYS_ENV = "MCP_EVENTS_SEALING_KEYS" +KEY_SEPARATOR = ":" +KEYS_SEPARATOR = "," +KEY_BYTES = 32 +# A random 32-byte key has ~30 distinct byte values; fewer than this means a +# repeated or hand-typed key (all zeros, "aaaa…", a short passphrase). +KEY_MIN_DISTINCT_BYTES = 16 +KEY_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,128}") +"""The profile's ``kid`` syntax.""" +COMPACT_PARTS = 5 +"""``header..iv.ciphertext.tag``: JWE Compact with an empty encrypted key.""" + +RECORD_VERSION = 1 +DELIVERY_MODE = "webhook" + +SUBSCRIPTION_PREFIX = "sub_" +# Appwrite custom ids: at most 36 chars of [A-Za-z0-9._-], no leading special +# char. "sub_" + 32 hex chars (128 bits of SHA-256) fills that exactly. +SUBSCRIPTION_HASH_CHARS = 32 +SUBSCRIPTION_ID_MAX = len(SUBSCRIPTION_PREFIX) + SUBSCRIPTION_HASH_CHARS +SUBSCRIPTION_ID_PATTERN = re.compile( + rf"{SUBSCRIPTION_PREFIX}[0-9a-f]{{{SUBSCRIPTION_HASH_CHARS}}}" +) +"""Exactly what :func:`subscription_id` produces: the prefix and 32 lowercase +hex characters.""" +PRINCIPAL_BYTES = 16 + +# Longest envelope :meth:`Keyring.seal` produces, and the `authPassword` length +# Appwrite must accept (appwrite/appwrite#14293; today ~99 chars). The realistic +# worst case (300-char callback URL, a 64-byte secret, the longest catalog +# arguments) measures 1580 chars; a typical subscription (150-char URL, a +# 32-byte secret) about 1320. See tests/unit/test_events_envelope.py. +ENVELOPE_BUDGET = 2048 + +SIGNATURE_INFO = b"mcp-events/appwrite-signature/v1" + +EPOCH = datetime(1970, 1, 1, tzinfo=UTC) +MILLISECOND = timedelta(milliseconds=1) + +Argument = str | int | bool | None + + +class EnvelopeFailure(StrEnum): + """Why an envelope did not open.""" + + MALFORMED = "malformed" + """Not a JWE Compact token with a readable key id.""" + KEY = "unknown_key" + AUTHENTICATION = "authentication_failed" + """Tampered, not the profile, or bound to another server, project or + webhook.""" + BINDING = "binding_mismatch" + """Opened, but its contents do not hash to the subscription id.""" + + +class EnvelopeError(Exception): + """An envelope that must not be trusted. The ingress drops the delivery.""" + + def __init__(self, failure: EnvelopeFailure, message: str) -> None: + super().__init__(message) + self.failure = failure + + +class SubscriptionExpired(Exception): + """The envelope opened and is bound to this webhook, but its lease has + ended. The ingress acknowledges and drops the delivery.""" + + +class EnvelopeTooLarge(ValueError): + """The sealed subscription exceeds :data:`ENVELOPE_BUDGET`, almost always + because of a very long callback URL. Subscribe rejects it as invalid params.""" + + def __init__(self, length: int) -> None: + super().__init__( + f"sealed subscription is {length} chars; the limit is {ENVELOPE_BUDGET}" + ) + self.length = length + + +class InvalidSubscription(ValueError): + """The subscription is not a valid Subscription Record v1, e.g. a callback + URL with credentials or a fragment, or a non-canonical secret.""" + + +class KeyringError(ValueError): + """``MCP_EVENTS_SEALING_KEYS`` is missing or invalid.""" + + +def canonical_json(value: Any) -> bytes: + """Sorted keys, compact separators, UTF-8, no NaN or Infinity.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def subscription_id( + principal: str, callback: str, name: str, arguments: Mapping[str, Argument] +) -> str: + """Deterministic id over (principal, callback URL, event name, arguments). + + A valid Appwrite custom id, used as the webhook ``$id``. Argument order does + not change it.""" + material = canonical_json([principal, callback, name, dict(arguments)]) + digest = hashlib.sha256(material).hexdigest() + return SUBSCRIPTION_PREFIX + digest[:SUBSCRIPTION_HASH_CHARS] + + +def valid_subscription_id(value: str) -> bool: + """Whether ``value`` is a subscription id this module could have produced.""" + return SUBSCRIPTION_ID_PATTERN.fullmatch(value) is not None + + +def timestamp(milliseconds: int) -> str: + """UTC RFC 3339 with milliseconds and ``Z``, as the record's lease and + ``refreshBefore`` are written.""" + moment = EPOCH + milliseconds * MILLISECOND + return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def milliseconds(stamp: str) -> int: + """Unix epoch milliseconds of a record timestamp.""" + return (datetime.fromisoformat(stamp) - EPOCH) // MILLISECOND + + +@dataclass(frozen=True) +class Principal: + """The OAuth caller a subscription belongs to. Only its digest is kept.""" + + issuer: str + subject: str + client: str + + @property + def digest(self) -> str: + """base64url of the first 128 bits of SHA-256 over (iss, sub, client_id).""" + material = canonical_json([self.issuer, self.subject, self.client]) + return _encode(hashlib.sha256(material).digest()[:PRINCIPAL_BYTES]) + + +@dataclass(frozen=True) +class Context: + """What an envelope is bound to. When opening, every field comes from + trusted configuration and routing, never from the envelope.""" + + server: str + """This deployment: ``MCP_PUBLIC_URL``.""" + tenant: str + """The Appwrite project.""" + resource: str + """The subscription id, which is also the webhook id.""" + + def as_dict(self) -> dict[str, str]: + return { + "server": self.server, + "tenant": self.tenant, + "resource": self.resource, + } + + +@dataclass(frozen=True) +class Subscription: + """Everything the ingress needs to deliver one subscription's events: a + typed view of the Subscription Record v1 that is sealed, plus the project + it is bound to.""" + + id: str + project: str + name: str + """Event name, e.g. ``tablesdb.row.created``.""" + arguments: Mapping[str, Argument] + """Event arguments, sorted by key and read-only.""" + callback: str + """Delivery URL.""" + secret: str = field(repr=False) + """The client's ``whsec_`` secret. Kept out of ``repr`` so logging a + subscription never leaks it.""" + expires: int + """Lease expiry as Unix epoch milliseconds.""" + refresh: int + """``refreshBefore`` as Unix epoch milliseconds.""" + principal: str + """:attr:`Principal.digest` of the subscriber.""" + + def __post_init__(self) -> None: + canonical = MappingProxyType(dict(sorted(self.arguments.items()))) + object.__setattr__(self, "arguments", canonical) + expected = subscription_id( + self.principal, self.callback, self.name, self.arguments + ) + if self.id != expected: + raise ValueError("subscription id does not match its contents") + + @classmethod + def create( + cls, + project: str, + name: str, + arguments: Mapping[str, Argument], + callback: str, + secret: str, + expires: int, + refresh: int, + principal: str, + ) -> Subscription: + """Build a subscription, deriving its id.""" + return cls( + id=subscription_id(principal, callback, name, arguments), + project=project, + name=name, + arguments=arguments, + callback=callback, + secret=secret, + expires=expires, + refresh=refresh, + principal=principal, + ) + + @classmethod + def from_record(cls, record: Mapping[str, Any], project: str) -> Subscription: + """The subscription of a validated record. Raises ``ValueError`` when + its id does not match its contents.""" + return cls( + id=record["id"], + project=project, + name=record["event"]["name"], + arguments=record["event"]["arguments"], + callback=record["delivery"]["url"], + secret=record["delivery"]["secret"], + expires=milliseconds(record["lease"]["expiresAt"]), + refresh=milliseconds(record["lease"]["refreshBefore"]), + principal=record["principal"], + ) + + @property + def record(self) -> dict[str, Any]: + """The Subscription Record v1 that is sealed.""" + return { + "version": RECORD_VERSION, + "id": self.id, + "principal": self.principal, + "event": {"name": self.name, "arguments": dict(self.arguments)}, + "delivery": { + "mode": DELIVERY_MODE, + "url": self.callback, + "secret": self.secret, + }, + "lease": { + "expiresAt": timestamp(self.expires), + "refreshBefore": timestamp(self.refresh), + }, + "extensions": {}, + } + + def context(self, server: str) -> Context: + return Context(server=server, tenant=self.project, resource=self.id) + + +@dataclass(frozen=True) +class SealingKey: + """One entry of the keyring: 32 random bytes, used as the JWE key and to + derive the Appwrite signing key with HKDF.""" + + id: str + material: bytes + + def __post_init__(self) -> None: + if KEY_ID_PATTERN.fullmatch(self.id) is None: + raise KeyringError( + f"sealing key id {self.id!r} must match {KEY_ID_PATTERN.pattern}" + ) + if len(self.material) != KEY_BYTES: + raise KeyringError( + f"sealing key {self.id!r} must be {KEY_BYTES} bytes, " + f"got {len(self.material)}" + ) + if len(set(self.material)) < KEY_MIN_DISTINCT_BYTES: + raise KeyringError( + f"sealing key {self.id!r} is not random; generate one with " + "`openssl rand -base64 32`" + ) + + @property + def signing(self) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, info=SIGNATURE_INFO + ).derive(self.material) + + +@dataclass(frozen=True) +class Keyring: + """Sealing keys, the active (sealing) one first. Any of them opens.""" + + keys: tuple[SealingKey, ...] + + def __post_init__(self) -> None: + if not self.keys: + raise KeyringError(f"{KEYS_ENV} must name at least one sealing key") + ids = [key.id for key in self.keys] + if len(set(ids)) != len(ids): + raise KeyringError(f"{KEYS_ENV} repeats a sealing key id") + + @classmethod + def parse(cls, text: str) -> Keyring: + """Parse ``:[,:...]``.""" + keys: list[SealingKey] = [] + for entry in text.split(KEYS_SEPARATOR): + entry = entry.strip() + if not entry: + continue + key_id, separator, encoded = entry.partition(KEY_SEPARATOR) + if not separator: + raise KeyringError( + f"{KEYS_ENV} entries must look like :" + ) + try: + material = _decode_key(encoded.strip()) + except ValueError as error: + raise KeyringError( + f"sealing key {key_id.strip()!r} is not valid base64" + ) from error + keys.append(SealingKey(id=key_id.strip(), material=material)) + return cls(keys=tuple(keys)) + + @classmethod + def from_env(cls) -> Keyring: + """Read the keyring from ``MCP_EVENTS_SEALING_KEYS``.""" + text = os.getenv(KEYS_ENV, "").strip() + if not text: + raise KeyringError( + f"{KEYS_ENV} is not set; MCP Events needs at least one sealing " + f"key, e.g. {KEYS_ENV}=k1:$(openssl rand -base64 32)" + ) + return cls.parse(text) + + @property + def active(self) -> SealingKey: + return self.keys[0] + + @property + def materials(self) -> dict[str, bytes]: + """The ring as the package takes it: key id to key bytes.""" + return {key.id: key.material for key in self.keys} + + def get(self, key_id: str) -> SealingKey: + for key in self.keys: + if key.id == key_id: + return key + raise EnvelopeError(EnvelopeFailure.KEY, f"unknown sealing key {key_id!r}") + + def seal(self, subscription: Subscription, server: str) -> str: + """Seal ``subscription`` with the active key, bound to ``server``, its + project and its id. + + Raises :class:`InvalidSubscription` for a record the profile refuses + and :class:`EnvelopeTooLarge` above :data:`ENVELOPE_BUDGET`.""" + try: + envelope = profile.seal( + subscription.record, + self.materials, + active_key=self.active.id, + context=subscription.context(server).as_dict(), + ) + except profile.RecordError as error: + raise InvalidSubscription(str(error)) from error + if len(envelope) > ENVELOPE_BUDGET: + raise EnvelopeTooLarge(len(envelope)) + return envelope + + def record( + self, envelope: str, context: Context, *, now: datetime | None = None + ) -> dict[str, Any]: + """The Subscription Record v1 in an envelope bound to ``context``, as + the profile opens it. + + Raises :class:`EnvelopeError` when it is malformed, names an unknown + key, was tampered with, or was sealed for another server, project or + webhook, and :class:`SubscriptionExpired` once its lease has ended at + ``now`` (default the current time; fixtures pin it).""" + self.get(key_id(envelope)) + try: + return profile.open_envelope( + envelope, + self.materials, + expected_context=context.as_dict(), + now=now, + ) + except profile.ExpiredSubscription as error: + raise SubscriptionExpired(str(error)) from error + except profile.EnvelopeError as error: + raise EnvelopeError( + EnvelopeFailure.AUTHENTICATION, + "envelope was tampered with or belongs to another webhook", + ) from error + + def open( + self, envelope: str, context: Context, *, now: datetime | None = None + ) -> Subscription: + """Open an envelope for the webhook and project in ``context``: the + checks of :meth:`record`, then that its contents hash to the + subscription id (``binding_mismatch`` otherwise).""" + record = self.record(envelope, context, now=now) + try: + return Subscription.from_record(record, context.tenant) + except ValueError as error: + raise EnvelopeError( + EnvelopeFailure.BINDING, + "envelope contents do not match the subscription id", + ) from error + + def signing_key(self, id: str, key_id: str | None = None) -> str: + """Appwrite webhook ``secret`` for subscription ``id``: hex + HMAC-SHA256 under the signing key derived from ``key_id`` (default the + active key). 64 chars, within Appwrite's ``Text(256, 8)``. The ingress + passes the key id of the delivery's envelope, since a webhook is always + written with a matching envelope and secret.""" + key = self.get(key_id) if key_id is not None else self.active + return hmac.new(key.signing, id.encode("utf-8"), hashlib.sha256).hexdigest() + + +def key_id(envelope: str) -> str: + """The ``kid`` in an envelope's protected header, read without opening it. + + It only picks the key to try; the header is authenticated when the + envelope opens. Raises :class:`EnvelopeError` (``malformed``) for anything + that is not JWE Compact with a valid key id.""" + parts = envelope.split(".") + if len(parts) != COMPACT_PARTS or parts[1]: + raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope is not JWE Compact") + try: + header = json.loads(_decode(parts[0])) + except ValueError as error: + raise EnvelopeError( + EnvelopeFailure.MALFORMED, "envelope header is not base64url JSON" + ) from error + kid = header.get("kid") if isinstance(header, dict) else None + if not isinstance(kid, str) or KEY_ID_PATTERN.fullmatch(kid) is None: + raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope key id is invalid") + return kid + + +def appwrite_signature(url: str, body: bytes, key: str) -> str: + """``X-Appwrite-Webhook-Signature``, computed the way Appwrite's webhooks + worker does: ``base64_encode(hash_hmac('sha1', $url . $payload, $key, true))``. + + ``url`` is the webhook's configured URL (our ingress URL as registered), not + the URL the request arrived on, which a proxy may have rewritten.""" + digest = hmac.new(key.encode("utf-8"), url.encode("utf-8") + body, hashlib.sha1) + return base64.b64encode(digest.digest()).decode("ascii") + + +def verify_appwrite_signature(url: str, body: bytes, signature: str, key: str) -> bool: + """Constant-time check of an ``X-Appwrite-Webhook-Signature`` header.""" + expected = appwrite_signature(url, body, key) + return hmac.compare_digest(expected.encode("ascii"), signature.encode("utf-8")) + + +def _encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _decode(text: str) -> bytes: + if re.fullmatch(r"[A-Za-z0-9_-]*", text) is None: + raise ValueError("invalid base64url") + try: + return base64.urlsafe_b64decode((text + "=" * (-len(text) % 4)).encode("ascii")) + except binascii.Error as error: + raise ValueError("invalid base64url") from error + + +def _decode_key(text: str) -> bytes: + """Standard or URL-safe base64, with or without padding.""" + normalized = text.replace("-", "+").replace("_", "/") + try: + return base64.b64decode( + normalized + "=" * (-len(normalized) % 4), validate=True + ) + except binascii.Error as error: + raise ValueError("invalid base64") from error diff --git a/tests/unit/fixtures/jwe-v1.json b/tests/unit/fixtures/jwe-v1.json new file mode 100644 index 0000000..a99624c --- /dev/null +++ b/tests/unit/fixtures/jwe-v1.json @@ -0,0 +1,48 @@ +{ + "description": "PUBLIC TEST KEY AND FIXED IV: never use in production", + "keyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "ivHex": "000102030405060708090a0b", + "protected": { + "alg": "dir", + "enc": "A256GCM", + "kid": "fixture-k1", + "typ": "mcp-subscription+jwe", + "cty": "application/json" + }, + "context": { + "server": "https://mcp.example.com", + "tenant": "project_demo", + "resource": "sub_example" + }, + "plaintext": { + "profile": "mcp-subscription-envelope-v1", + "context": { + "server": "https://mcp.example.com", + "tenant": "project_demo", + "resource": "sub_example" + }, + "subscription": { + "version": 1, + "id": "sub_example", + "principal": "principal_demo", + "event": { + "name": "functions.execution.failed", + "arguments": { + "project_id": "project_demo", + "function_id": "fn_demo" + } + }, + "delivery": { + "mode": "webhook", + "url": "https://receiver.example.com/events", + "secret": "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + }, + "lease": { + "expiresAt": "2030-01-01T01:00:00Z", + "refreshBefore": "2030-01-01T00:54:00Z" + }, + "extensions": {} + } + }, + "token": "eyJhbGciOiJkaXIiLCJjdHkiOiJhcHBsaWNhdGlvbi9qc29uIiwiZW5jIjoiQTI1NkdDTSIsImtpZCI6ImZpeHR1cmUtazEiLCJ0eXAiOiJtY3Atc3Vic2NyaXB0aW9uK2p3ZSJ9..AAECAwQFBgcICQoL.PCC1dKuRp2P5Y63wk5sdHuyj9VeVWWVeSxKH2ngRYd9xfMveg-Nh_QbSGp-qvQpQmi0Q_mD5jLdc5wR8YIKYnpxZ6Bm8vARNPiDPAI7hesoCqLkQGQk2C4mi-qkVn5vw59oplMsnoXb7PgSQJRZRUskSANiAt0zqOZY4o6-DAHjePm-20fq9YirNatJIuD-hRJ2TQVx-_Wi2JMoTn76AmPbl5zfThO4TyAe42OWi8d7_pqVN4Ilfnha6o7UUsZXJMTviiWi-iYh2E5CnzRCfxoYdsTVsb_a1MKHVgzkJ3b8rboHptHlbYhAs2U_NZhgZwKuEV7ES_Nd7SV1R4-g28Wfr3b_vvytbHqkXdZlIQmu3xKaorujuwl5KGzRkgMlpfC_8LcygoGOwBkF4a-fHpvWvNSo6tvVsuldoePRJLIanyhZOqVpaj82_H0UwvD9OsWql6_Zx5nwWyhxEt5M4jTZFnLrxCxauTcCGXruXDEnpmD1DzJd1r_yRXbOyJHdX3eajrPNuvLKPFp8WUiV2pZ8nTt7nVDe6TnmxKD7EgS1kVnUCp_NrUNPNvg2agNHILQchMDChTICGyobPvNA9HQIKx6_LJP4JySDCZcGn5XxZu0Zi8s4uDXn1ioohz9yWNc1bZPHhur056GHrh96IoqqO46oOnswQhi-hbJPYYyaUWNDIj2v-Km2ZkQ9TfALR99NXGFRZosbmCyNexzVy7whqW-jOa5J9VaYkz6UBedd30uo.11BkPstaF5zWAYhg92Q1gA" +} diff --git a/tests/unit/test_events_envelope.py b/tests/unit/test_events_envelope.py new file mode 100644 index 0000000..84371d6 --- /dev/null +++ b/tests/unit/test_events_envelope.py @@ -0,0 +1,266 @@ +"""Envelope properties that no e2e flow can establish. + +Sealing, opening, tampering (401), server, project and webhook binding, key +rotation across server restarts and expiry are covered end to end through the +ingress in ``tests/e2e/test_events_ingress.py`` (PR 4 of #127). What stays here: + +* The published ``mcp-subscription+jwe`` conformance vector from + mcp-event-subscriptions, opened through this adapter. Every envelope in the + e2e flows is sealed and opened by this server, so only the vector ties the + format to the profile. +* The Appwrite signature against a vector computed by PHP, the way Appwrite's + webhooks worker does it. The e2e harness signs deliveries with its own + implementation of the same formula, so only this vector ties both to PHP. +* Subscription id determinism and its Appwrite custom-id charset. Nothing + derives an id from a subscribe request until ``events/subscribe`` (PR 5), + whose e2e flow should replace these tests. +* The envelope size budget that appwrite/appwrite#14293 must accommodate: + worst-case inputs that no realistic flow would send. +""" + +import base64 +import json +import os +import unittest +from datetime import UTC, datetime +from pathlib import Path + +from mcp_server_appwrite.events.envelope import ( + ENVELOPE_BUDGET, + SUBSCRIPTION_ID_MAX, + Context, + EnvelopeError, + EnvelopeFailure, + EnvelopeTooLarge, + Keyring, + Principal, + Subscription, + appwrite_signature, + milliseconds, + subscription_id, + valid_subscription_id, + verify_appwrite_signature, +) + +VECTOR = Path(__file__).parent / "fixtures" / "jwe-v1.json" +"""``test-vectors/encryption/jwe-v1.json`` from +https://github.com/ChiragAgg5k/mcp-event-subscriptions (0.1.0a1), unchanged.""" + +APPWRITE_ID = "a" * 36 +SERVER = "https://mcp.appwrite.io" +EXPIRES = 1_760_000_000_000 +REFRESH = EXPIRES - 360_000 + + +def key(seed: int) -> bytes: + return bytes((seed + index * 7) % 256 for index in range(32)) + + +def ring(*entries: tuple[str, bytes]) -> Keyring: + return Keyring.parse( + ",".join( + f"{name}:{base64.b64encode(material).decode()}" + for name, material in entries + ) + ) + + +def secret(size: int) -> str: + return "whsec_" + base64.b64encode(os.urandom(size)).decode() + + +PRINCIPAL = Principal( + issuer="https://cloud.appwrite.io/v1/oauth2/console", + subject="66b1c2d3e4f5a6b7c8d9", + client="chatgpt-connector", +).digest + + +class ProfileVectorTests(unittest.TestCase): + def test_opens_the_published_vector(self): + vector = json.loads(VECTOR.read_text()) + record = vector["plaintext"]["subscription"] + keyring = ring( + (vector["protected"]["kid"], bytes.fromhex(vector["keyHex"])), + ("k1", key(1)), + ) + context = Context(**vector["context"]) + # The vector's lease ends in 2030; pin the clock before it, as the + # package's own conformance tests do. + now = datetime(2029, 1, 1, tzinfo=UTC) + + self.assertEqual(keyring.record(vector["token"], context, now=now), record) + # The vector's id is not one subscription_id() derives, so the adapter + # refuses it only for binding, after the profile accepted it. + with self.assertRaises(EnvelopeError) as raised: + keyring.open(vector["token"], context, now=now) + self.assertEqual(raised.exception.failure, EnvelopeFailure.BINDING) + + # The vector's record is exactly what this adapter seals, given an id + # derived from its contents. + ours = Subscription.create( + project=context.tenant, + name=record["event"]["name"], + arguments=record["event"]["arguments"], + callback=record["delivery"]["url"], + secret=record["delivery"]["secret"], + expires=milliseconds(record["lease"]["expiresAt"]), + refresh=milliseconds(record["lease"]["refreshBefore"]), + principal=record["principal"], + ) + self.assertEqual( + Subscription.from_record({**record, "id": ours.id}, context.tenant), ours + ) + # Logging a subscription never shows its secret. + self.assertNotIn(record["delivery"]["secret"], repr(ours)) + + +class SubscriptionIdTests(unittest.TestCase): + def test_deterministic(self): + first = subscription_id(PRINCIPAL, "https://a.test/x", "users.user.created", {}) + second = subscription_id( + PRINCIPAL, "https://a.test/x", "users.user.created", {} + ) + self.assertEqual(first, second) + + def test_argument_order_does_not_matter(self): + forward = subscription_id( + PRINCIPAL, + "https://a.test/x", + "tablesdb.row.created", + {"project_id": "p", "database_id": "d", "table_id": "t"}, + ) + backward = subscription_id( + PRINCIPAL, + "https://a.test/x", + "tablesdb.row.created", + {"table_id": "t", "database_id": "d", "project_id": "p"}, + ) + self.assertEqual(forward, backward) + + def test_every_input_changes_the_id(self): + base = ("principal", "https://a.test/x", "users.user.created", {"a": "1"}) + variants = ( + ("other", *base[1:]), + (base[0], "https://a.test/y", *base[2:]), + (*base[:2], "storage.file.created", base[3]), + (*base[:3], {"a": "2"}), + ) + original = subscription_id(*base) + for variant in variants: + with self.subTest(variant=variant): + self.assertNotEqual(subscription_id(*variant), original) + + def test_is_a_valid_appwrite_custom_id(self): + # Mirrors utopia-php/database Key + Appwrite CustomId: at most 36 chars + # of [A-Za-z0-9._-], not starting with "_", "." or "-". + for index in range(200): + value = subscription_id(f"principal-{index}", "https://a.test", "e", {}) + with self.subTest(value=value): + self.assertLessEqual(len(value), SUBSCRIPTION_ID_MAX) + self.assertRegex(value, r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + self.assertTrue(valid_subscription_id(value)) + + def test_rejects_foreign_ids(self): + for value in ( + "unique()", + "_sub", + "sub_short", + "x" * 36, + "sub_" + "g" * 31 + "!", + # Right length and charset for Appwrite, but not a SHA-256 prefix. + "sub_" + "z" * 32, + "sub_" + "A" * 32, + "sub_" + "0" * 31 + ".", + "sub_" + "0" * 33, + ): + with self.subTest(value=value): + self.assertFalse(valid_subscription_id(value)) + + def test_principal_digest_is_stable_and_short(self): + again = Principal( + issuer="https://cloud.appwrite.io/v1/oauth2/console", + subject="66b1c2d3e4f5a6b7c8d9", + client="chatgpt-connector", + ).digest + other = Principal( + issuer="https://cloud.appwrite.io/v1/oauth2/console", + subject="66b1c2d3e4f5a6b7c8d9", + client="another-client", + ).digest + self.assertEqual(PRINCIPAL, again) + self.assertNotEqual(PRINCIPAL, other) + self.assertEqual(len(PRINCIPAL), 22) + + def test_signing_key_fits_the_appwrite_webhook_secret(self): + # Appwrite's webhook `secret` param is Text(256, 8). + signing = ring(("k1", key(1))).signing_key("sub_" + "0" * 32) + self.assertRegex(signing, r"^[0-9a-f]{64}$") + + +class AppwriteSignatureTests(unittest.TestCase): + # Produced with PHP 8.5, the way Appwrite's webhooks worker signs + # (src/Appwrite/Platform/Workers/Webhooks.php): + # php -r '$url="https://mcp.appwrite.io/appwrite/webhooks/sub_0123456789abcdef0123456789abcdef"; + # $payload="{\"\$id\":\"6630f1a2b3c4d5e6f7a8\",\"status\":\"failed\"}"; + # $key="appwrite-signing-key-for-tests"; + # echo base64_encode(hash_hmac("sha1", $url . $payload, $key, true));' + URL = ( + "https://mcp.appwrite.io/appwrite/webhooks/sub_0123456789abcdef0123456789abcdef" + ) + BODY = b'{"$id":"6630f1a2b3c4d5e6f7a8","status":"failed"}' + KEY = "appwrite-signing-key-for-tests" + SIGNATURE = "SgM0XNLzyHAwzlgQA2iZykDQI8M=" + + def test_known_vector(self): + self.assertEqual( + appwrite_signature(self.URL, self.BODY, self.KEY), self.SIGNATURE + ) + self.assertTrue( + verify_appwrite_signature(self.URL, self.BODY, self.SIGNATURE, self.KEY) + ) + + def test_rejects_changes(self): + cases = ( + (self.URL + "x", self.BODY, self.SIGNATURE, self.KEY), + (self.URL, self.BODY + b" ", self.SIGNATURE, self.KEY), + (self.URL, self.BODY, self.SIGNATURE, self.KEY + "x"), + (self.URL, self.BODY, "AgM0XNLzyHAwzlgQA2iZykDQI8M=", self.KEY), + (self.URL, self.BODY, "", self.KEY), + (self.URL, self.BODY, "é", self.KEY), + ) + for case in cases: + with self.subTest(case=case): + self.assertFalse(verify_appwrite_signature(*case)) + + +class SizeBudgetTests(unittest.TestCase): + """The worst realistic case must fit the budget that appwrite/appwrite#14293 + has to let `authPassword` hold, and nothing larger is ever sealed.""" + + def seal(self, url: int, size: int) -> int: + sealed = Subscription.create( + project=APPWRITE_ID, + name="functions.deployment.completed", + arguments={ + "project_id": APPWRITE_ID, + "function_id": APPWRITE_ID, + "status": "failed", + }, + callback="https://" + "x" * (url - len("https://")), + secret=secret(size), + expires=EXPIRES, + refresh=REFRESH, + principal=PRINCIPAL, + ) + return len(ring(("k1", key(1))).seal(sealed, SERVER)) + + def test_worst_case_fits_and_larger_is_refused(self): + # 300-char callback URL, 64-byte secret, the longest catalog arguments. + self.assertLessEqual(self.seal(300, 64), ENVELOPE_BUDGET) + with self.assertRaises(EnvelopeTooLarge): + self.seal(ENVELOPE_BUDGET, 32) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index d82a05d..dafc3b7 100644 --- a/uv.lock +++ b/uv.lock @@ -622,6 +622,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jwcrypto" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/8f/e802d77957903483129813ce6fa01a72a906f9cdd454039d1ec1e6d1e44a/jwcrypto-1.6.1.tar.gz", hash = "sha256:a1e1570da5c2e35dbcd375ec1d2891a24de8eb35ee2c1fe8e91978efb72cfe90", size = 116287, upload-time = "2026-09-15T21:22:33.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a4/ee780473e42a3edaf65da1ad37f5c601a9c740e215c1e09db36bf90f3f59/jwcrypto-1.6.1-py3-none-any.whl", hash = "sha256:77e856818e514cb3d64a1043b6885a7d52f5cc37773f5e944de02103c8d3a54f", size = 121267, upload-time = "2026-09-15T21:22:32.27Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -665,15 +678,30 @@ cli = [ { name = "typer" }, ] +[[package]] +name = "mcp-event-subscriptions" +version = "0.1.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jwcrypto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2e/f6af884063973af62b006525f9accfd889b423af7548c4fc169ed27505a6/mcp_event_subscriptions-0.1.0a1.tar.gz", hash = "sha256:2c34ea0af0605d27ca23a842092275b57b482ab2d47288e10d3bfcd88fbef19d", size = 75263, upload-time = "2026-10-11T20:24:35.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/13/b3c37eb259cca28a19bcd374a54f3ce8ce903a6f8a61df23c324318e36f1/mcp_event_subscriptions-0.1.0a1-py3-none-any.whl", hash = "sha256:d0bf02ca46e462b4b9d2deaf790ba5828254edbc36033cf509a64e47da31330b", size = 7437, upload-time = "2026-10-11T20:24:34.194Z" }, +] + [[package]] name = "mcp-server-appwrite" source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "appwrite-console" }, + { name = "cryptography" }, { name = "docstring-parser" }, { name = "httpx" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-event-subscriptions" }, { name = "numpy" }, { name = "openai" }, { name = "opentelemetry-api" }, @@ -710,9 +738,11 @@ requires-dist = [ { name = "appwrite-console", specifier = ">=0.10.0,<0.11" }, { name = "argon2-cffi", marker = "extra == 'integration'", specifier = ">=23.1.0" }, { name = "bcrypt", marker = "extra == 'integration'", specifier = ">=4.1.2" }, + { name = "cryptography", specifier = ">=44.0.0" }, { name = "docstring-parser", specifier = ">=0.16" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", extras = ["cli"], specifier = ">=2.3,<3" }, + { name = "mcp-event-subscriptions", specifier = ">=0.1.0a1,<0.2" }, { name = "numpy", specifier = ">=2.0" }, { name = "openai", specifier = ">=1.40" }, { name = "opentelemetry-api", specifier = ">=1.28" },