From a952407d48801e6bc9c3e328a0113e00f87ad434 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 13:09:00 +0100 Subject: [PATCH 1/6] feat: seal MCP event subscriptions into an envelope Each MCP Events subscription is stored only in its Appwrite webhook. The envelope seals the delivery state into authPassword with AES-256-GCM, bound to the subscription id and project, and the webhook signing key is derived so the ingress can verify deliveries without any storage. --- .env.example | 6 + docs/development.md | 5 + pyproject.toml | 1 + src/mcp_server_appwrite/events/envelope.py | 486 +++++++++++++++++++ tests/unit/test_events_envelope.py | 518 +++++++++++++++++++++ uv.lock | 2 + 6 files changed, 1018 insertions(+) create mode 100644 src/mcp_server_appwrite/events/envelope.py create mode 100644 tests/unit/test_events_envelope.py diff --git a/.env.example b/.env.example index 4f62699..cdecab3 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,12 @@ PORT=8000 # SENTRY_DSN=https://public@sentry.example/1 # SENTRY_ENVIRONMENT=development +# Optional. MCP Events sealing keys: comma-separated :, 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..b2a2dd1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,6 +37,11 @@ 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 with the keys in +> `MCP_EVENTS_SEALING_KEYS` (`:`, 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..3a1bc6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "starlette>=0.40.0", "uvicorn[standard]>=0.30.0", "httpx>=0.27.0", + "cryptography>=44.0.0", "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..6a91bea --- /dev/null +++ b/src/mcp_server_appwrite/events/envelope.py @@ -0,0 +1,486 @@ +"""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`: + ``v1..``. The ciphertext is + AES-256-GCM over compact JSON of the subscription. The subscription id and the + project id are not in the plaintext; they are bound as associated data, so an + envelope copied onto another webhook or project 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. + +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 +import time +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +KEYS_ENV = "MCP_EVENTS_SEALING_KEYS" +VERSION = "v1" +SEPARATOR = "." +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_-]+") +BASE64URL_PATTERN = re.compile(r"[A-Za-z0-9_-]*") +NONCE_BYTES = 12 +TAG_BYTES = 16 + +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 = 36 +SUBSCRIPTION_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +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, current and previous 64-byte secrets, the +# longest catalog arguments) measures 1034 chars; a typical subscription +# (150-char URL, one 32-byte secret) about 650. See tests/unit/test_events_envelope.py. +ENVELOPE_BUDGET = 2048 + +SEAL_INFO = b"mcp-events/seal/v1" +SIGNATURE_INFO = b"mcp-events/appwrite-signature/v1" + +Argument = str | int | bool | None + + +class EnvelopeFailure(StrEnum): + """Why an envelope did not open.""" + + MALFORMED = "malformed" + VERSION = "unsupported_version" + KEY = "unknown_key" + AUTHENTICATION = "authentication_failed" + """Tampered, or sealed for a different subscription id or project.""" + BINDING = "binding_mismatch" + """Opened, but its contents do not hash to the subscription id.""" + + +class Field(StrEnum): + """Plaintext keys, one letter each to keep the envelope small.""" + + NAME = "n" + ARGUMENTS = "a" + CALLBACK = "c" + SECRETS = "s" + EXPIRES = "e" + PRINCIPAL = "p" + + +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 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 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 ( + len(value) == SUBSCRIPTION_ID_MAX + and value.startswith(SUBSCRIPTION_PREFIX) + and SUBSCRIPTION_ID_PATTERN.fullmatch(value) is not None + ) + + +@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 Subscription: + """Everything the ingress needs to deliver one subscription's events.""" + + 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.""" + secrets: tuple[str, ...] + """``whsec_`` secrets: the current one first, the previous one during rotation.""" + expires: int + """Expiry 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) + object.__setattr__(self, "secrets", tuple(self.secrets)) + if not self.secrets: + raise ValueError("a subscription needs at least one secret") + 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, + secrets: tuple[str, ...], + expires: 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, + secrets=secrets, + expires=expires, + principal=principal, + ) + + def expired(self, now: int | None = None) -> bool: + """Whether the subscription has expired at ``now`` (epoch ms, default + the current time). Kept apart from opening so the ingress can answer an + expired delivery with a 2xx drop and an invalid one differently.""" + current = now if now is not None else time.time_ns() // 1_000_000 + return current >= self.expires + + +@dataclass(frozen=True) +class Envelope: + """The parsed ``..`` form of a sealed envelope.""" + + version: str + key: str + """Id of the sealing key; also selects the Appwrite signing key.""" + payload: bytes + """``nonce | ciphertext | tag``.""" + + @classmethod + def parse(cls, text: str) -> Envelope: + parts = text.split(SEPARATOR) + if len(parts) != 3: + raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope is malformed") + version, key, payload = parts + if version != VERSION: + raise EnvelopeError( + EnvelopeFailure.VERSION, f"unsupported envelope version {version!r}" + ) + if KEY_ID_PATTERN.fullmatch(key) is None: + raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope key id is invalid") + try: + raw = _decode(payload) + except ValueError as error: + raise EnvelopeError( + EnvelopeFailure.MALFORMED, "envelope payload is not base64url" + ) from error + if len(raw) < NONCE_BYTES + TAG_BYTES: + raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope is truncated") + return cls(version=version, key=key, payload=raw) + + def __str__(self) -> str: + return SEPARATOR.join((self.version, self.key, _encode(self.payload))) + + +@dataclass(frozen=True) +class SealingKey: + """One entry of the keyring. ``material`` is the configured 32 bytes; + separate encryption and signing keys are derived from it 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 encryption(self) -> bytes: + return _derive(self.material, SEAL_INFO) + + @property + def signing(self) -> bytes: + return _derive(self.material, SIGNATURE_INFO) + + +@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] + + 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) -> str: + """Seal ``subscription`` with the active key into an envelope string. + + Raises :class:`EnvelopeTooLarge` above :data:`ENVELOPE_BUDGET`.""" + key = self.active + nonce = os.urandom(NONCE_BYTES) + sealed = AESGCM(key.encryption).encrypt( + nonce, + _plaintext(subscription), + _associated(key.id, subscription.id, subscription.project), + ) + envelope = str(Envelope(version=VERSION, key=key.id, payload=nonce + sealed)) + if len(envelope) > ENVELOPE_BUDGET: + raise EnvelopeTooLarge(len(envelope)) + return envelope + + def open(self, envelope: str, id: str, project: str) -> Subscription: + """Open an envelope for the webhook ``id`` in ``project``. + + Raises :class:`EnvelopeError` when it is malformed, names an unknown key + or version, was tampered with, or was sealed for another webhook or + project. Expiry is not checked here; see :meth:`Subscription.expired`.""" + parsed = Envelope.parse(envelope) + key = self.get(parsed.key) + nonce, sealed = parsed.payload[:NONCE_BYTES], parsed.payload[NONCE_BYTES:] + try: + plaintext = AESGCM(key.encryption).decrypt( + nonce, sealed, _associated(key.id, id, project) + ) + except InvalidTag as error: + raise EnvelopeError( + EnvelopeFailure.AUTHENTICATION, + "envelope was tampered with or belongs to another webhook", + ) from error + try: + fields = json.loads(plaintext) + return Subscription( + id=id, + project=project, + name=fields[Field.NAME], + arguments=fields[Field.ARGUMENTS], + callback=fields[Field.CALLBACK], + secrets=tuple(fields[Field.SECRETS]), + expires=fields[Field.EXPIRES], + principal=fields[Field.PRINCIPAL], + ) + except (ValueError, KeyError, TypeError) 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 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 _plaintext(subscription: Subscription) -> bytes: + return canonical_json( + { + Field.NAME: subscription.name, + Field.ARGUMENTS: dict(subscription.arguments), + Field.CALLBACK: subscription.callback, + Field.SECRETS: list(subscription.secrets), + Field.EXPIRES: subscription.expires, + Field.PRINCIPAL: subscription.principal, + } + ) + + +def _associated(key_id: str, id: str, project: str) -> bytes: + return canonical_json([VERSION, key_id, id, project]) + + +def _derive(material: bytes, info: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, info=info + ).derive(material) + + +def _encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _decode(text: str) -> bytes: + if BASE64URL_PATTERN.fullmatch(text) is None: + raise ValueError("invalid base64url") + try: + return base64.urlsafe_b64decode((text + "=" * (-len(text) % 4)).encode("ascii")) + except (binascii.Error, UnicodeEncodeError) 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/test_events_envelope.py b/tests/unit/test_events_envelope.py new file mode 100644 index 0000000..51a526a --- /dev/null +++ b/tests/unit/test_events_envelope.py @@ -0,0 +1,518 @@ +import base64 +import os +import unittest +from unittest import mock + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from mcp_server_appwrite.events import envelope as module +from mcp_server_appwrite.events.envelope import ( + ENVELOPE_BUDGET, + KEYS_ENV, + NONCE_BYTES, + SUBSCRIPTION_ID_MAX, + TAG_BYTES, + Envelope, + EnvelopeError, + EnvelopeFailure, + EnvelopeTooLarge, + Keyring, + KeyringError, + Principal, + SealingKey, + Subscription, + appwrite_signature, + canonical_json, + subscription_id, + valid_subscription_id, + verify_appwrite_signature, +) + +PROJECT = "6630f1a2b3c4d5e6f7a8" +OTHER_PROJECT = "7740f1a2b3c4d5e6f7a8" +APPWRITE_ID = "a" * 36 +EXPIRES = 1_760_000_000_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 + + +def subscription( + callback: str = "https://chatgpt.com/backend-api/mcp/events/abc", + secrets: tuple[str, ...] | None = None, + expires: int = EXPIRES, +) -> Subscription: + return Subscription.create( + project=PROJECT, + name="tablesdb.row.created", + arguments={ + "project_id": PROJECT, + "database_id": "main", + "table_id": "support_tickets", + }, + callback=callback, + secrets=secrets or (secret(32),), + expires=expires, + principal=PRINCIPAL, + ) + + +class CanonicalJsonTests(unittest.TestCase): + def test_sorted_compact_utf8(self): + self.assertEqual( + canonical_json({"b": 1, "a": ["é", None]}), + '{"a":["é",null],"b":1}'.encode("utf-8"), + ) + + def test_rejects_nan(self): + with self.assertRaises(ValueError): + canonical_json({"a": float("nan")}) + + +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 + "!", + ): + with self.subTest(value=value): + self.assertFalse(valid_subscription_id(value)) + + def test_subscription_rejects_mismatched_id(self): + with self.assertRaises(ValueError): + Subscription( + id="sub_" + "0" * 32, + project=PROJECT, + name="users.user.created", + arguments={}, + callback="https://a.test", + secrets=(secret(32),), + expires=EXPIRES, + principal=PRINCIPAL, + ) + + 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) + + +class SealTests(unittest.TestCase): + def setUp(self): + self.keyring = ring(("k1", key(1))) + + def test_round_trip(self): + original = subscription(secrets=(secret(32), secret(24))) + envelope = self.keyring.seal(original) + self.assertTrue(envelope.startswith("v1.k1.")) + opened = self.keyring.open(envelope, original.id, PROJECT) + self.assertEqual(opened, original) + self.assertEqual( + list(opened.arguments), ["database_id", "project_id", "table_id"] + ) + + def test_nonce_is_random(self): + original = subscription() + self.assertNotEqual(self.keyring.seal(original), self.keyring.seal(original)) + + def test_envelope_is_basic_auth_safe(self): + envelope = self.keyring.seal(subscription()) + self.assertRegex(envelope, r"^[A-Za-z0-9._-]+$") + + def test_tampering_each_region_is_rejected(self): + original = subscription() + envelope = Envelope.parse(self.keyring.seal(original)) + ciphertext_end = len(envelope.payload) - TAG_BYTES + regions = { + "nonce": range(0, NONCE_BYTES), + "ciphertext": range(NONCE_BYTES, ciphertext_end), + "tag": range(ciphertext_end, len(envelope.payload)), + } + for region, offsets in regions.items(): + for offset in (offsets[0], offsets[len(offsets) // 2], offsets[-1]): + with self.subTest(region=region, offset=offset): + payload = bytearray(envelope.payload) + payload[offset] ^= 0x01 + tampered = Envelope(envelope.version, envelope.key, bytes(payload)) + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(str(tampered), original.id, PROJECT) + self.assertEqual( + caught.exception.failure, EnvelopeFailure.AUTHENTICATION + ) + + def test_truncated_and_extended_payloads_are_rejected(self): + original = subscription() + envelope = Envelope.parse(self.keyring.seal(original)) + for payload in (envelope.payload[:-1], envelope.payload + b"\x00"): + with self.subTest(length=len(payload)): + changed = Envelope(envelope.version, envelope.key, payload) + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(str(changed), original.id, PROJECT) + self.assertEqual( + caught.exception.failure, EnvelopeFailure.AUTHENTICATION + ) + + def test_wrong_version(self): + original = subscription() + envelope = "v2" + self.keyring.seal(original)[2:] + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(envelope, original.id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.VERSION) + + def test_malformed(self): + original = subscription() + for envelope in ( + "", + "v1", + "v1.k1", + "v1.k1.a.b", + "v1.k1.!!!!", + "v1.k1.AAAA", + "v1.k 1.AAAA", + ): + with self.subTest(envelope=envelope): + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(envelope, original.id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.MALFORMED) + + def test_bound_to_subscription_id(self): + original = subscription() + other = subscription( + callback="https://chatgpt.com/backend-api/mcp/events/other" + ) + envelope = self.keyring.seal(original) + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(envelope, other.id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) + + def test_bound_to_project(self): + original = subscription() + envelope = self.keyring.seal(original) + with self.assertRaises(EnvelopeError) as caught: + self.keyring.open(envelope, original.id, OTHER_PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) + + def test_contents_must_hash_to_the_id(self): + # An envelope sealed by a buggy writer under a mismatched id still opens + # (the AEAD binding holds) but its contents do not hash to that id. + original = subscription() + forged_id = "sub_" + "f" * 32 + keyring = self.keyring + material = keyring.active + sealed = AESGCM(material.encryption).encrypt( + b"\x00" * NONCE_BYTES, + module._plaintext(original), + module._associated(material.id, forged_id, PROJECT), + ) + forged = str(Envelope("v1", material.id, b"\x00" * NONCE_BYTES + sealed)) + with self.assertRaises(EnvelopeError) as caught: + keyring.open(forged, forged_id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.BINDING) + + +class RotationTests(unittest.TestCase): + def test_old_key_opens_new_key_seals(self): + old = ring(("k1", key(1))) + rotated = ring(("k2", key(2)), ("k1", key(1))) + original = subscription() + legacy = old.seal(original) + self.assertEqual(rotated.open(legacy, original.id, PROJECT), original) + fresh = rotated.seal(original) + self.assertTrue(fresh.startswith("v1.k2.")) + self.assertEqual(rotated.open(fresh, original.id, PROJECT), original) + + def test_unknown_key(self): + original = subscription() + envelope = ring(("k2", key(2))).seal(original) + with self.assertRaises(EnvelopeError) as caught: + ring(("k1", key(1))).open(envelope, original.id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.KEY) + + def test_key_id_swap_is_rejected(self): + # Same material under two ids: the key id is bound as associated data. + keyring = ring(("k1", key(1)), ("k2", key(1))) + original = subscription() + envelope = keyring.seal(original) + swapped = envelope.replace("v1.k1.", "v1.k2.", 1) + with self.assertRaises(EnvelopeError) as caught: + keyring.open(swapped, original.id, PROJECT) + self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) + + +class KeyringTests(unittest.TestCase): + def test_from_env(self): + encoded = base64.b64encode(key(3)).decode() + with mock.patch.dict( + os.environ, {KEYS_ENV: f" k3:{encoded} , k1:{encoded[:-1]}x= "} + ): + with self.assertRaises(KeyringError): + Keyring.from_env() + other = base64.urlsafe_b64encode(key(4)).decode().rstrip("=") + with mock.patch.dict(os.environ, {KEYS_ENV: f"k3:{encoded},k4:{other}"}): + keyring = Keyring.from_env() + self.assertEqual([entry.id for entry in keyring.keys], ["k3", "k4"]) + self.assertEqual(keyring.active.material, key(3)) + self.assertEqual(keyring.get("k4").material, key(4)) + + def test_missing(self): + with mock.patch.dict(os.environ, {KEYS_ENV: ""}): + with self.assertRaisesRegex(KeyringError, KEYS_ENV): + Keyring.from_env() + + def test_rejects_short_long_and_weak_keys(self): + for material in ( + os.urandom(16), + os.urandom(31), + os.urandom(33), + bytes(32), + b"a" * 32, + ): + with self.subTest(length=len(material)): + with self.assertRaises(KeyringError): + SealingKey(id="k1", material=material) + + def test_rejects_bad_entries(self): + good = base64.b64encode(key(1)).decode() + for text in ( + "", + good, + f"k.1:{good}", + f":{good}", + "k1:not base64!", + f"k1:{good},k1:{base64.b64encode(key(2)).decode()}", + ): + with self.subTest(text=text): + with self.assertRaises(KeyringError): + Keyring.parse(text) + + def test_signing_key_is_derived_and_valid_for_appwrite(self): + keyring = ring(("k2", key(2)), ("k1", key(1))) + first = keyring.signing_key("sub_" + "0" * 32) + self.assertEqual(first, keyring.signing_key("sub_" + "0" * 32, "k2")) + self.assertNotEqual(first, keyring.signing_key("sub_" + "1" * 32)) + self.assertNotEqual(first, keyring.signing_key("sub_" + "0" * 32, "k1")) + # Appwrite's webhook `secret` param is Text(256, 8). + self.assertTrue(8 <= len(first) <= 256) + self.assertRegex(first, r"^[0-9a-f]{64}$") + with self.assertRaises(EnvelopeError): + keyring.signing_key("sub_" + "0" * 32, "k9") + + +class ExpiryTests(unittest.TestCase): + def test_expired(self): + current = subscription(expires=EXPIRES) + self.assertFalse(current.expired(EXPIRES - 1)) + self.assertTrue(current.expired(EXPIRES)) + self.assertTrue(current.expired(EXPIRES + 1)) + + def test_defaults_to_now(self): + self.assertTrue(subscription(expires=1).expired()) + self.assertFalse(subscription(expires=2**53).expired()) + + def test_expired_envelope_still_opens(self): + keyring = ring(("k1", key(1))) + stale = subscription(expires=1) + opened = keyring.open(keyring.seal(stale), stale.id, PROJECT) + self.assertTrue(opened.expired()) + + +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)) + + def test_with_derived_key(self): + keyring = ring(("k1", key(1))) + signing = keyring.signing_key(self.URL.rsplit("/", 1)[1]) + signature = appwrite_signature(self.URL, self.BODY, signing) + self.assertTrue( + verify_appwrite_signature(self.URL, self.BODY, signature, signing) + ) + + +class SizeBudgetTests(unittest.TestCase): + """Envelope lengths for realistic cases. The worst case must fit the budget + that appwrite/appwrite#14293 has to let `authPassword` hold.""" + + def setUp(self): + self.keyring = ring(("k1", key(1))) + + def seal( + self, name: str, arguments: dict[str, str], url: int, secrets: tuple[int, ...] + ) -> int: + callback = "https://" + "x" * (url - len("https://")) + sealed = Subscription.create( + project=APPWRITE_ID, + name=name, + arguments=arguments, + callback=callback, + secrets=tuple(secret(size) for size in secrets), + expires=EXPIRES, + principal=PRINCIPAL, + ) + return len(self.keyring.seal(sealed)) + + def test_worst_cases_fit_the_budget(self): + rows = { + "project_id": APPWRITE_ID, + "database_id": APPWRITE_ID, + "table_id": APPWRITE_ID, + } + deployments = { + "project_id": APPWRITE_ID, + "function_id": APPWRITE_ID, + "status": "failed", + } + cases = { + "users.user.created, 100-char URL, one 32-byte secret": ( + "users.user.created", + {"project_id": APPWRITE_ID}, + 100, + (32,), + ), + "tablesdb.row.created, 150-char URL, one 32-byte secret": ( + "tablesdb.row.created", + rows, + 150, + (32,), + ), + "tablesdb.row.created, 300-char URL, two 64-byte secrets": ( + "tablesdb.row.created", + rows, + 300, + (64, 64), + ), + "functions.deployment.completed, 300-char URL, two 64-byte secrets": ( + "functions.deployment.completed", + deployments, + 300, + (64, 64), + ), + } + for label, (name, arguments, url, secrets) in cases.items(): + with self.subTest(case=label): + self.assertLessEqual( + self.seal(name, arguments, url, secrets), ENVELOPE_BUDGET + ) + + def test_seal_refuses_oversized_envelopes(self): + with self.assertRaises(EnvelopeTooLarge): + self.seal( + "users.user.created", + {"project_id": APPWRITE_ID}, + ENVELOPE_BUDGET, + (32,), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index d82a05d..46cf143 100644 --- a/uv.lock +++ b/uv.lock @@ -671,6 +671,7 @@ source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "appwrite-console" }, + { name = "cryptography" }, { name = "docstring-parser" }, { name = "httpx" }, { name = "mcp", extra = ["cli"] }, @@ -710,6 +711,7 @@ 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" }, From a995f8fdc8df0001709b77fe78dd9106f1b9a79d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 9 Oct 2026 14:39:21 +0100 Subject: [PATCH 2/6] test: keep only envelope tests that e2e cannot reach Round trips, tampering, binding, rotation and expiry are covered through the ingress e2e flows in the layer above. What stays is the PHP-computed Appwrite signature vector, subscription id determinism and charset until subscribe has an e2e flow, and the size budget for appwrite/appwrite#14293. --- tests/unit/test_events_envelope.py | 304 ++--------------------------- 1 file changed, 19 insertions(+), 285 deletions(-) diff --git a/tests/unit/test_events_envelope.py b/tests/unit/test_events_envelope.py index 51a526a..0f44d57 100644 --- a/tests/unit/test_events_envelope.py +++ b/tests/unit/test_events_envelope.py @@ -1,35 +1,36 @@ +"""Envelope properties that no e2e flow can establish. + +Sealing, opening, tampering (401), 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 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 os import unittest -from unittest import mock - -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from mcp_server_appwrite.events import envelope as module from mcp_server_appwrite.events.envelope import ( ENVELOPE_BUDGET, - KEYS_ENV, - NONCE_BYTES, SUBSCRIPTION_ID_MAX, - TAG_BYTES, - Envelope, - EnvelopeError, - EnvelopeFailure, EnvelopeTooLarge, Keyring, - KeyringError, Principal, - SealingKey, Subscription, appwrite_signature, - canonical_json, subscription_id, valid_subscription_id, verify_appwrite_signature, ) -PROJECT = "6630f1a2b3c4d5e6f7a8" -OTHER_PROJECT = "7740f1a2b3c4d5e6f7a8" APPWRITE_ID = "a" * 36 EXPIRES = 1_760_000_000_000 @@ -58,38 +59,6 @@ def secret(size: int) -> str: ).digest -def subscription( - callback: str = "https://chatgpt.com/backend-api/mcp/events/abc", - secrets: tuple[str, ...] | None = None, - expires: int = EXPIRES, -) -> Subscription: - return Subscription.create( - project=PROJECT, - name="tablesdb.row.created", - arguments={ - "project_id": PROJECT, - "database_id": "main", - "table_id": "support_tickets", - }, - callback=callback, - secrets=secrets or (secret(32),), - expires=expires, - principal=PRINCIPAL, - ) - - -class CanonicalJsonTests(unittest.TestCase): - def test_sorted_compact_utf8(self): - self.assertEqual( - canonical_json({"b": 1, "a": ["é", None]}), - '{"a":["é",null],"b":1}'.encode("utf-8"), - ) - - def test_rejects_nan(self): - with self.assertRaises(ValueError): - canonical_json({"a": float("nan")}) - - class SubscriptionIdTests(unittest.TestCase): def test_deterministic(self): first = subscription_id(PRINCIPAL, "https://a.test/x", "users.user.created", {}) @@ -147,19 +116,6 @@ def test_rejects_foreign_ids(self): with self.subTest(value=value): self.assertFalse(valid_subscription_id(value)) - def test_subscription_rejects_mismatched_id(self): - with self.assertRaises(ValueError): - Subscription( - id="sub_" + "0" * 32, - project=PROJECT, - name="users.user.created", - arguments={}, - callback="https://a.test", - secrets=(secret(32),), - expires=EXPIRES, - principal=PRINCIPAL, - ) - def test_principal_digest_is_stable_and_short(self): again = Principal( issuer="https://cloud.appwrite.io/v1/oauth2/console", @@ -175,224 +131,10 @@ def test_principal_digest_is_stable_and_short(self): self.assertNotEqual(PRINCIPAL, other) self.assertEqual(len(PRINCIPAL), 22) - -class SealTests(unittest.TestCase): - def setUp(self): - self.keyring = ring(("k1", key(1))) - - def test_round_trip(self): - original = subscription(secrets=(secret(32), secret(24))) - envelope = self.keyring.seal(original) - self.assertTrue(envelope.startswith("v1.k1.")) - opened = self.keyring.open(envelope, original.id, PROJECT) - self.assertEqual(opened, original) - self.assertEqual( - list(opened.arguments), ["database_id", "project_id", "table_id"] - ) - - def test_nonce_is_random(self): - original = subscription() - self.assertNotEqual(self.keyring.seal(original), self.keyring.seal(original)) - - def test_envelope_is_basic_auth_safe(self): - envelope = self.keyring.seal(subscription()) - self.assertRegex(envelope, r"^[A-Za-z0-9._-]+$") - - def test_tampering_each_region_is_rejected(self): - original = subscription() - envelope = Envelope.parse(self.keyring.seal(original)) - ciphertext_end = len(envelope.payload) - TAG_BYTES - regions = { - "nonce": range(0, NONCE_BYTES), - "ciphertext": range(NONCE_BYTES, ciphertext_end), - "tag": range(ciphertext_end, len(envelope.payload)), - } - for region, offsets in regions.items(): - for offset in (offsets[0], offsets[len(offsets) // 2], offsets[-1]): - with self.subTest(region=region, offset=offset): - payload = bytearray(envelope.payload) - payload[offset] ^= 0x01 - tampered = Envelope(envelope.version, envelope.key, bytes(payload)) - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(str(tampered), original.id, PROJECT) - self.assertEqual( - caught.exception.failure, EnvelopeFailure.AUTHENTICATION - ) - - def test_truncated_and_extended_payloads_are_rejected(self): - original = subscription() - envelope = Envelope.parse(self.keyring.seal(original)) - for payload in (envelope.payload[:-1], envelope.payload + b"\x00"): - with self.subTest(length=len(payload)): - changed = Envelope(envelope.version, envelope.key, payload) - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(str(changed), original.id, PROJECT) - self.assertEqual( - caught.exception.failure, EnvelopeFailure.AUTHENTICATION - ) - - def test_wrong_version(self): - original = subscription() - envelope = "v2" + self.keyring.seal(original)[2:] - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(envelope, original.id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.VERSION) - - def test_malformed(self): - original = subscription() - for envelope in ( - "", - "v1", - "v1.k1", - "v1.k1.a.b", - "v1.k1.!!!!", - "v1.k1.AAAA", - "v1.k 1.AAAA", - ): - with self.subTest(envelope=envelope): - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(envelope, original.id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.MALFORMED) - - def test_bound_to_subscription_id(self): - original = subscription() - other = subscription( - callback="https://chatgpt.com/backend-api/mcp/events/other" - ) - envelope = self.keyring.seal(original) - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(envelope, other.id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) - - def test_bound_to_project(self): - original = subscription() - envelope = self.keyring.seal(original) - with self.assertRaises(EnvelopeError) as caught: - self.keyring.open(envelope, original.id, OTHER_PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) - - def test_contents_must_hash_to_the_id(self): - # An envelope sealed by a buggy writer under a mismatched id still opens - # (the AEAD binding holds) but its contents do not hash to that id. - original = subscription() - forged_id = "sub_" + "f" * 32 - keyring = self.keyring - material = keyring.active - sealed = AESGCM(material.encryption).encrypt( - b"\x00" * NONCE_BYTES, - module._plaintext(original), - module._associated(material.id, forged_id, PROJECT), - ) - forged = str(Envelope("v1", material.id, b"\x00" * NONCE_BYTES + sealed)) - with self.assertRaises(EnvelopeError) as caught: - keyring.open(forged, forged_id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.BINDING) - - -class RotationTests(unittest.TestCase): - def test_old_key_opens_new_key_seals(self): - old = ring(("k1", key(1))) - rotated = ring(("k2", key(2)), ("k1", key(1))) - original = subscription() - legacy = old.seal(original) - self.assertEqual(rotated.open(legacy, original.id, PROJECT), original) - fresh = rotated.seal(original) - self.assertTrue(fresh.startswith("v1.k2.")) - self.assertEqual(rotated.open(fresh, original.id, PROJECT), original) - - def test_unknown_key(self): - original = subscription() - envelope = ring(("k2", key(2))).seal(original) - with self.assertRaises(EnvelopeError) as caught: - ring(("k1", key(1))).open(envelope, original.id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.KEY) - - def test_key_id_swap_is_rejected(self): - # Same material under two ids: the key id is bound as associated data. - keyring = ring(("k1", key(1)), ("k2", key(1))) - original = subscription() - envelope = keyring.seal(original) - swapped = envelope.replace("v1.k1.", "v1.k2.", 1) - with self.assertRaises(EnvelopeError) as caught: - keyring.open(swapped, original.id, PROJECT) - self.assertEqual(caught.exception.failure, EnvelopeFailure.AUTHENTICATION) - - -class KeyringTests(unittest.TestCase): - def test_from_env(self): - encoded = base64.b64encode(key(3)).decode() - with mock.patch.dict( - os.environ, {KEYS_ENV: f" k3:{encoded} , k1:{encoded[:-1]}x= "} - ): - with self.assertRaises(KeyringError): - Keyring.from_env() - other = base64.urlsafe_b64encode(key(4)).decode().rstrip("=") - with mock.patch.dict(os.environ, {KEYS_ENV: f"k3:{encoded},k4:{other}"}): - keyring = Keyring.from_env() - self.assertEqual([entry.id for entry in keyring.keys], ["k3", "k4"]) - self.assertEqual(keyring.active.material, key(3)) - self.assertEqual(keyring.get("k4").material, key(4)) - - def test_missing(self): - with mock.patch.dict(os.environ, {KEYS_ENV: ""}): - with self.assertRaisesRegex(KeyringError, KEYS_ENV): - Keyring.from_env() - - def test_rejects_short_long_and_weak_keys(self): - for material in ( - os.urandom(16), - os.urandom(31), - os.urandom(33), - bytes(32), - b"a" * 32, - ): - with self.subTest(length=len(material)): - with self.assertRaises(KeyringError): - SealingKey(id="k1", material=material) - - def test_rejects_bad_entries(self): - good = base64.b64encode(key(1)).decode() - for text in ( - "", - good, - f"k.1:{good}", - f":{good}", - "k1:not base64!", - f"k1:{good},k1:{base64.b64encode(key(2)).decode()}", - ): - with self.subTest(text=text): - with self.assertRaises(KeyringError): - Keyring.parse(text) - - def test_signing_key_is_derived_and_valid_for_appwrite(self): - keyring = ring(("k2", key(2)), ("k1", key(1))) - first = keyring.signing_key("sub_" + "0" * 32) - self.assertEqual(first, keyring.signing_key("sub_" + "0" * 32, "k2")) - self.assertNotEqual(first, keyring.signing_key("sub_" + "1" * 32)) - self.assertNotEqual(first, keyring.signing_key("sub_" + "0" * 32, "k1")) + def test_signing_key_fits_the_appwrite_webhook_secret(self): # Appwrite's webhook `secret` param is Text(256, 8). - self.assertTrue(8 <= len(first) <= 256) - self.assertRegex(first, r"^[0-9a-f]{64}$") - with self.assertRaises(EnvelopeError): - keyring.signing_key("sub_" + "0" * 32, "k9") - - -class ExpiryTests(unittest.TestCase): - def test_expired(self): - current = subscription(expires=EXPIRES) - self.assertFalse(current.expired(EXPIRES - 1)) - self.assertTrue(current.expired(EXPIRES)) - self.assertTrue(current.expired(EXPIRES + 1)) - - def test_defaults_to_now(self): - self.assertTrue(subscription(expires=1).expired()) - self.assertFalse(subscription(expires=2**53).expired()) - - def test_expired_envelope_still_opens(self): - keyring = ring(("k1", key(1))) - stale = subscription(expires=1) - opened = keyring.open(keyring.seal(stale), stale.id, PROJECT) - self.assertTrue(opened.expired()) + signing = ring(("k1", key(1))).signing_key("sub_" + "0" * 32) + self.assertRegex(signing, r"^[0-9a-f]{64}$") class AppwriteSignatureTests(unittest.TestCase): @@ -430,14 +172,6 @@ def test_rejects_changes(self): with self.subTest(case=case): self.assertFalse(verify_appwrite_signature(*case)) - def test_with_derived_key(self): - keyring = ring(("k1", key(1))) - signing = keyring.signing_key(self.URL.rsplit("/", 1)[1]) - signature = appwrite_signature(self.URL, self.BODY, signing) - self.assertTrue( - verify_appwrite_signature(self.URL, self.BODY, signature, signing) - ) - class SizeBudgetTests(unittest.TestCase): """Envelope lengths for realistic cases. The worst case must fit the budget From d14a2209ca7fe4214e99f9b55e862b8a1ed5ca3d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 11 Oct 2026 21:00:54 +0100 Subject: [PATCH 3/6] fix: accept only generated subscription ids valid_subscription_id accepted any 32-char Appwrite-safe suffix, so ids like sub_zzzz... passed as ours although subscription_id() only ever emits 32 lowercase hex characters. The ingress route and managed-webhook recognition use it to tell our ids from foreign ones, so they now reject anything else. --- src/mcp_server_appwrite/events/envelope.py | 14 +++++++------- tests/unit/test_events_envelope.py | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/mcp_server_appwrite/events/envelope.py b/src/mcp_server_appwrite/events/envelope.py index 6a91bea..39c5d40 100644 --- a/src/mcp_server_appwrite/events/envelope.py +++ b/src/mcp_server_appwrite/events/envelope.py @@ -64,8 +64,12 @@ # 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 = 36 -SUBSCRIPTION_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +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 @@ -152,11 +156,7 @@ def subscription_id( def valid_subscription_id(value: str) -> bool: """Whether ``value`` is a subscription id this module could have produced.""" - return ( - len(value) == SUBSCRIPTION_ID_MAX - and value.startswith(SUBSCRIPTION_PREFIX) - and SUBSCRIPTION_ID_PATTERN.fullmatch(value) is not None - ) + return SUBSCRIPTION_ID_PATTERN.fullmatch(value) is not None @dataclass(frozen=True) diff --git a/tests/unit/test_events_envelope.py b/tests/unit/test_events_envelope.py index 0f44d57..f66864d 100644 --- a/tests/unit/test_events_envelope.py +++ b/tests/unit/test_events_envelope.py @@ -112,6 +112,11 @@ def test_rejects_foreign_ids(self): "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)) From 7ede4997ea964ab483133819af1a14df74e39778 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 11 Oct 2026 21:52:48 +0100 Subject: [PATCH 4/6] refactor: seal subscriptions with the mcp-event-subscriptions JWE profile The custom v1.. envelope becomes a Subscription Record v1 sealed with the package's mcp-subscription+jwe profile, bound to this server, the project and the webhook id. The adapter keeps what the profile leaves to us: subscription ids, the principal digest, the derived Appwrite signing key and the authPassword size budget. --- .env.example | 7 +- docs/development.md | 10 +- pyproject.toml | 1 + src/mcp_server_appwrite/events/envelope.py | 337 ++++++++++++--------- tests/unit/fixtures/jwe-v1.json | 48 +++ tests/unit/test_events_envelope.py | 141 ++++----- uv.lock | 28 ++ 7 files changed, 348 insertions(+), 224 deletions(-) create mode 100644 tests/unit/fixtures/jwe-v1.json diff --git a/.env.example b/.env.example index cdecab3..8b44076 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,10 @@ PORT=8000 # SENTRY_DSN=https://public@sentry.example/1 # SENTRY_ENVIRONMENT=development -# Optional. MCP Events sealing keys: comma-separated :, 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. +# 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: diff --git a/docs/development.md b/docs/development.md index b2a2dd1..86db250 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,10 +37,12 @@ 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 with the keys in -> `MCP_EVENTS_SEALING_KEYS` (`:`, 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. +> 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:** diff --git a/pyproject.toml b/pyproject.toml index 3a1bc6c..e7b81f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "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 index 39c5d40..65c5879 100644 --- a/src/mcp_server_appwrite/events/envelope.py +++ b/src/mcp_server_appwrite/events/envelope.py @@ -7,18 +7,25 @@ * 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`: - ``v1..``. The ciphertext is - AES-256-GCM over compact JSON of the subscription. The subscription id and the - project id are not in the plaintext; they are bound as associated data, so an - envelope copied onto another webhook or project 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. +* ``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 @@ -34,31 +41,31 @@ import json import os import re -import time from collections.abc import Mapping from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from enum import StrEnum from types import MappingProxyType from typing import Any -from cryptography.exceptions import InvalidTag +import mcp_event_subscriptions as profile from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF KEYS_ENV = "MCP_EVENTS_SEALING_KEYS" -VERSION = "v1" -SEPARATOR = "." 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_-]+") -BASE64URL_PATTERN = re.compile(r"[A-Za-z0-9_-]*") -NONCE_BYTES = 12 -TAG_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 @@ -74,14 +81,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, current and previous 64-byte secrets, the -# longest catalog arguments) measures 1034 chars; a typical subscription -# (150-char URL, one 32-byte secret) about 650. See tests/unit/test_events_envelope.py. +# 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 -SEAL_INFO = b"mcp-events/seal/v1" SIGNATURE_INFO = b"mcp-events/appwrite-signature/v1" +EPOCH = datetime(1970, 1, 1, tzinfo=UTC) +MILLISECOND = timedelta(milliseconds=1) + Argument = str | int | bool | None @@ -89,25 +98,15 @@ class EnvelopeFailure(StrEnum): """Why an envelope did not open.""" MALFORMED = "malformed" - VERSION = "unsupported_version" + """Not a JWE Compact token with a readable key id.""" KEY = "unknown_key" AUTHENTICATION = "authentication_failed" - """Tampered, or sealed for a different subscription id or project.""" + """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 Field(StrEnum): - """Plaintext keys, one letter each to keep the envelope small.""" - - NAME = "n" - ARGUMENTS = "a" - CALLBACK = "c" - SECRETS = "s" - EXPIRES = "e" - PRINCIPAL = "p" - - class EnvelopeError(Exception): """An envelope that must not be trusted. The ingress drops the delivery.""" @@ -116,6 +115,11 @@ def __init__(self, failure: EnvelopeFailure, message: str) -> None: 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.""" @@ -127,6 +131,11 @@ def __init__(self, length: int) -> None: 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.""" @@ -159,6 +168,18 @@ def valid_subscription_id(value: str) -> bool: 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.""" @@ -174,9 +195,31 @@ def digest(self) -> str: 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.""" + """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 @@ -186,19 +229,18 @@ class Subscription: """Event arguments, sorted by key and read-only.""" callback: str """Delivery URL.""" - secrets: tuple[str, ...] - """``whsec_`` secrets: the current one first, the previous one during rotation.""" + secret: str + """The client's ``whsec_`` secret.""" expires: int - """Expiry as Unix epoch milliseconds.""" + """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) - object.__setattr__(self, "secrets", tuple(self.secrets)) - if not self.secrets: - raise ValueError("a subscription needs at least one secret") expected = subscription_id( self.principal, self.callback, self.name, self.arguments ) @@ -212,8 +254,9 @@ def create( name: str, arguments: Mapping[str, Argument], callback: str, - secrets: tuple[str, ...], + secret: str, expires: int, + refresh: int, principal: str, ) -> Subscription: """Build a subscription, deriving its id.""" @@ -223,59 +266,56 @@ def create( name=name, arguments=arguments, callback=callback, - secrets=secrets, + secret=secret, expires=expires, + refresh=refresh, principal=principal, ) - def expired(self, now: int | None = None) -> bool: - """Whether the subscription has expired at ``now`` (epoch ms, default - the current time). Kept apart from opening so the ingress can answer an - expired delivery with a 2xx drop and an invalid one differently.""" - current = now if now is not None else time.time_ns() // 1_000_000 - return current >= self.expires - - -@dataclass(frozen=True) -class Envelope: - """The parsed ``..`` form of a sealed envelope.""" - - version: str - key: str - """Id of the sealing key; also selects the Appwrite signing key.""" - payload: bytes - """``nonce | ciphertext | tag``.""" - @classmethod - def parse(cls, text: str) -> Envelope: - parts = text.split(SEPARATOR) - if len(parts) != 3: - raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope is malformed") - version, key, payload = parts - if version != VERSION: - raise EnvelopeError( - EnvelopeFailure.VERSION, f"unsupported envelope version {version!r}" - ) - if KEY_ID_PATTERN.fullmatch(key) is None: - raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope key id is invalid") - try: - raw = _decode(payload) - except ValueError as error: - raise EnvelopeError( - EnvelopeFailure.MALFORMED, "envelope payload is not base64url" - ) from error - if len(raw) < NONCE_BYTES + TAG_BYTES: - raise EnvelopeError(EnvelopeFailure.MALFORMED, "envelope is truncated") - return cls(version=version, key=key, payload=raw) + 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"], + ) - def __str__(self) -> str: - return SEPARATOR.join((self.version, self.key, _encode(self.payload))) + @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. ``material`` is the configured 32 bytes; - separate encryption and signing keys are derived from it with HKDF.""" + """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 @@ -296,13 +336,11 @@ def __post_init__(self) -> None: "`openssl rand -base64 32`" ) - @property - def encryption(self) -> bytes: - return _derive(self.material, SEAL_INFO) - @property def signing(self) -> bytes: - return _derive(self.material, SIGNATURE_INFO) + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, info=SIGNATURE_INFO + ).derive(self.material) @dataclass(frozen=True) @@ -355,59 +393,64 @@ def from_env(cls) -> Keyring: 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) -> str: - """Seal ``subscription`` with the active key into an envelope string. + def seal(self, subscription: Subscription, server: str) -> str: + """Seal ``subscription`` with the active key, bound to ``server``, its + project and its id. - Raises :class:`EnvelopeTooLarge` above :data:`ENVELOPE_BUDGET`.""" - key = self.active - nonce = os.urandom(NONCE_BYTES) - sealed = AESGCM(key.encryption).encrypt( - nonce, - _plaintext(subscription), - _associated(key.id, subscription.id, subscription.project), - ) - envelope = str(Envelope(version=VERSION, key=key.id, payload=nonce + sealed)) + 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 open(self, envelope: str, id: str, project: str) -> Subscription: - """Open an envelope for the webhook ``id`` in ``project``. + def record(self, envelope: str, context: Context) -> 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 - or version, was tampered with, or was sealed for another webhook or - project. Expiry is not checked here; see :meth:`Subscription.expired`.""" - parsed = Envelope.parse(envelope) - key = self.get(parsed.key) - nonce, sealed = parsed.payload[:NONCE_BYTES], parsed.payload[NONCE_BYTES:] + 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.""" + self.get(key_id(envelope)) try: - plaintext = AESGCM(key.encryption).decrypt( - nonce, sealed, _associated(key.id, id, project) + return profile.open_envelope( + envelope, self.materials, expected_context=context.as_dict() ) - except InvalidTag as error: + 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) -> 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) try: - fields = json.loads(plaintext) - return Subscription( - id=id, - project=project, - name=fields[Field.NAME], - arguments=fields[Field.ARGUMENTS], - callback=fields[Field.CALLBACK], - secrets=tuple(fields[Field.SECRETS]), - expires=fields[Field.EXPIRES], - principal=fields[Field.PRINCIPAL], - ) - except (ValueError, KeyError, TypeError) as error: + return Subscription.from_record(record, context.tenant) + except ValueError as error: raise EnvelopeError( EnvelopeFailure.BINDING, "envelope contents do not match the subscription id", @@ -423,6 +466,27 @@ def signing_key(self, id: str, key_id: str | None = None) -> str: 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))``. @@ -439,39 +503,16 @@ def verify_appwrite_signature(url: str, body: bytes, signature: str, key: str) - return hmac.compare_digest(expected.encode("ascii"), signature.encode("utf-8")) -def _plaintext(subscription: Subscription) -> bytes: - return canonical_json( - { - Field.NAME: subscription.name, - Field.ARGUMENTS: dict(subscription.arguments), - Field.CALLBACK: subscription.callback, - Field.SECRETS: list(subscription.secrets), - Field.EXPIRES: subscription.expires, - Field.PRINCIPAL: subscription.principal, - } - ) - - -def _associated(key_id: str, id: str, project: str) -> bytes: - return canonical_json([VERSION, key_id, id, project]) - - -def _derive(material: bytes, info: bytes) -> bytes: - return HKDF( - algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, info=info - ).derive(material) - - def _encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def _decode(text: str) -> bytes: - if BASE64URL_PATTERN.fullmatch(text) is None: + 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, UnicodeEncodeError) as error: + except binascii.Error as error: raise ValueError("invalid base64url") 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 index f66864d..5e62183 100644 --- a/tests/unit/test_events_envelope.py +++ b/tests/unit/test_events_envelope.py @@ -1,9 +1,13 @@ """Envelope properties that no e2e flow can establish. -Sealing, opening, tampering (401), 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: - +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. @@ -15,24 +19,36 @@ """ import base64 +import json import os import unittest +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: @@ -59,6 +75,40 @@ def secret(size: int) -> str: ).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"]) + + self.assertEqual(keyring.record(vector["token"], context), 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) + 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 + ) + + class SubscriptionIdTests(unittest.TestCase): def test_deterministic(self): first = subscription_id(PRINCIPAL, "https://a.test/x", "users.user.created", {}) @@ -179,78 +229,31 @@ def test_rejects_changes(self): class SizeBudgetTests(unittest.TestCase): - """Envelope lengths for realistic cases. The worst case must fit the budget - that appwrite/appwrite#14293 has to let `authPassword` hold.""" + """The worst realistic case must fit the budget that appwrite/appwrite#14293 + has to let `authPassword` hold, and nothing larger is ever sealed.""" - def setUp(self): - self.keyring = ring(("k1", key(1))) - - def seal( - self, name: str, arguments: dict[str, str], url: int, secrets: tuple[int, ...] - ) -> int: - callback = "https://" + "x" * (url - len("https://")) + def seal(self, url: int, size: int) -> int: sealed = Subscription.create( project=APPWRITE_ID, - name=name, - arguments=arguments, - callback=callback, - secrets=tuple(secret(size) for size in secrets), + 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(self.keyring.seal(sealed)) - - def test_worst_cases_fit_the_budget(self): - rows = { - "project_id": APPWRITE_ID, - "database_id": APPWRITE_ID, - "table_id": APPWRITE_ID, - } - deployments = { - "project_id": APPWRITE_ID, - "function_id": APPWRITE_ID, - "status": "failed", - } - cases = { - "users.user.created, 100-char URL, one 32-byte secret": ( - "users.user.created", - {"project_id": APPWRITE_ID}, - 100, - (32,), - ), - "tablesdb.row.created, 150-char URL, one 32-byte secret": ( - "tablesdb.row.created", - rows, - 150, - (32,), - ), - "tablesdb.row.created, 300-char URL, two 64-byte secrets": ( - "tablesdb.row.created", - rows, - 300, - (64, 64), - ), - "functions.deployment.completed, 300-char URL, two 64-byte secrets": ( - "functions.deployment.completed", - deployments, - 300, - (64, 64), - ), - } - for label, (name, arguments, url, secrets) in cases.items(): - with self.subTest(case=label): - self.assertLessEqual( - self.seal(name, arguments, url, secrets), ENVELOPE_BUDGET - ) - - def test_seal_refuses_oversized_envelopes(self): + 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( - "users.user.created", - {"project_id": APPWRITE_ID}, - ENVELOPE_BUDGET, - (32,), - ) + self.seal(ENVELOPE_BUDGET, 32) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 46cf143..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,6 +678,19 @@ 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 = "." } @@ -675,6 +701,7 @@ dependencies = [ { name = "docstring-parser" }, { name = "httpx" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-event-subscriptions" }, { name = "numpy" }, { name = "openai" }, { name = "opentelemetry-api" }, @@ -715,6 +742,7 @@ requires-dist = [ { 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" }, From d4d593161b0ddd45d44a6f08e7a9e5b177890caf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 11 Oct 2026 22:11:14 +0100 Subject: [PATCH 5/6] fix: pin the clock for the conformance vector and keep secrets out of repr The published vector's lease ends in 2030, so Keyring.record and open take an optional clock that the vector test pins, as the package's own tests do. A Subscription's whsec_ secret is no longer part of its repr. --- src/mcp_server_appwrite/events/envelope.py | 25 +++++++++++++++------- tests/unit/test_events_envelope.py | 8 +++++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/mcp_server_appwrite/events/envelope.py b/src/mcp_server_appwrite/events/envelope.py index 65c5879..f111301 100644 --- a/src/mcp_server_appwrite/events/envelope.py +++ b/src/mcp_server_appwrite/events/envelope.py @@ -42,7 +42,7 @@ import os import re from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from enum import StrEnum from types import MappingProxyType @@ -229,8 +229,9 @@ class Subscription: """Event arguments, sorted by key and read-only.""" callback: str """Delivery URL.""" - secret: str - """The client's ``whsec_`` secret.""" + 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 @@ -423,17 +424,23 @@ def seal(self, subscription: Subscription, server: str) -> str: raise EnvelopeTooLarge(len(envelope)) return envelope - def record(self, envelope: str, context: Context) -> dict[str, Any]: + 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.""" + 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() + envelope, + self.materials, + expected_context=context.as_dict(), + now=now, ) except profile.ExpiredSubscription as error: raise SubscriptionExpired(str(error)) from error @@ -443,11 +450,13 @@ def record(self, envelope: str, context: Context) -> dict[str, Any]: "envelope was tampered with or belongs to another webhook", ) from error - def open(self, envelope: str, context: Context) -> Subscription: + 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) + record = self.record(envelope, context, now=now) try: return Subscription.from_record(record, context.tenant) except ValueError as error: diff --git a/tests/unit/test_events_envelope.py b/tests/unit/test_events_envelope.py index 5e62183..8364248 100644 --- a/tests/unit/test_events_envelope.py +++ b/tests/unit/test_events_envelope.py @@ -22,6 +22,7 @@ import json import os import unittest +from datetime import UTC, datetime from pathlib import Path from mcp_server_appwrite.events.envelope import ( @@ -84,12 +85,15 @@ def test_opens_the_published_vector(self): ("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), record) + 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) + 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 From 350cea43b325cae30eb7c2f6d938f056eb36908f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 11 Oct 2026 22:12:51 +0100 Subject: [PATCH 6/6] test: check a subscription's repr hides its secret --- tests/unit/test_events_envelope.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_events_envelope.py b/tests/unit/test_events_envelope.py index 8364248..84371d6 100644 --- a/tests/unit/test_events_envelope.py +++ b/tests/unit/test_events_envelope.py @@ -111,6 +111,8 @@ def test_opens_the_published_vector(self): 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):