mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
First-class iMessage support via Photon's managed Spectrum platform. Targeted as a successor to the BlueBubbles adapter — Photon allocates the iMessage line, handles delivery, and abuse-prevention so users don't have to run their own Mac relay. Free tier uses Photon's shared line pool. Architecture: - Inbound: signed JSON webhooks (X-Spectrum-Signature, HMAC-SHA256) delivered to a local aiohttp listener. Dedupes on message.id, rejects deliveries with >5min timestamp drift. - Outbound: small supervised Node sidecar that runs the spectrum-ts SDK. Photon does not currently expose a public HTTP send-message endpoint; the sidecar is the only way to call Space.send() today. When Photon ships an HTTP send endpoint we collapse the sidecar into _sidecar_send and drop the Node dep — every other layer of the plugin stays the same. - Setup: 'hermes photon login' runs the RFC 8628 device-code flow; 'hermes photon setup' creates a Spectrum-enabled project, creates a shared user (free tier), installs the sidecar's npm deps. - Webhook management: 'hermes photon webhook register|list|delete'. - Credentials persisted under credential_pool.photon / credential_pool.photon_project in ~/.hermes/auth.json. Plugin path (not built-in) — per current policy (May 2026), all new platforms ship under plugins/platforms/. Registers itself via ctx.register_platform() + ctx.register_cli_command(), zero edits to core gateway code. Tests cover: - HMAC-SHA256 signature verification (happy path, tampered body, wrong secret, drift, missing v0 prefix, empty inputs, non-integer timestamp) - Inbound dispatch for text DMs, group ids (any;+;...), and attachment metadata markers - Deduplication window - check_requirements gating when Node is absent - Device-code flow: request, header-based token return, body-fallback token return, access_denied propagation - Project/user/webhook API clients with mocked httpx Known limitations (current Photon API): - Attachments are metadata only — no download URL yet - Outbound attachment send not wired (sidecar can add easily) - Reactions / message effects not exposed yet Docs: website/docs/user-guide/messaging/photon.md + sidebar entry.
95 lines
2.6 KiB
Python
95 lines
2.6 KiB
Python
"""Signature verification tests for the Photon webhook receiver."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from plugins.platforms.photon.adapter import verify_signature
|
|
|
|
|
|
def _sign(secret: str, body: bytes, ts: int) -> str:
|
|
return "v0=" + hmac.new(
|
|
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
|
|
def test_accepts_valid_signature() -> None:
|
|
secret = "topsecret-32chars-or-whatever"
|
|
body = b'{"event":"messages"}'
|
|
ts = int(time.time())
|
|
sig = _sign(secret, body, ts)
|
|
assert verify_signature(
|
|
body=body, timestamp_header=str(ts), signature_header=sig,
|
|
signing_secret=secret,
|
|
)
|
|
|
|
|
|
def test_rejects_tampered_body() -> None:
|
|
secret = "s"
|
|
body = b'{"event":"messages"}'
|
|
ts = int(time.time())
|
|
sig = _sign(secret, body, ts)
|
|
assert not verify_signature(
|
|
body=body + b" tamper", timestamp_header=str(ts),
|
|
signature_header=sig, signing_secret=secret,
|
|
)
|
|
|
|
|
|
def test_rejects_wrong_secret() -> None:
|
|
body = b"x"
|
|
ts = int(time.time())
|
|
sig = _sign("right", body, ts)
|
|
assert not verify_signature(
|
|
body=body, timestamp_header=str(ts), signature_header=sig,
|
|
signing_secret="wrong",
|
|
)
|
|
|
|
|
|
def test_rejects_drifted_timestamp() -> None:
|
|
secret = "s"
|
|
body = b"x"
|
|
ts = int(time.time()) - 3600 # 1h old; drift window is 5 min
|
|
sig = _sign(secret, body, ts)
|
|
assert not verify_signature(
|
|
body=body, timestamp_header=str(ts), signature_header=sig,
|
|
signing_secret=secret,
|
|
)
|
|
|
|
|
|
def test_rejects_missing_v0_prefix() -> None:
|
|
secret = "s"
|
|
body = b"x"
|
|
ts = int(time.time())
|
|
raw_hex = hmac.new(
|
|
secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256,
|
|
).hexdigest()
|
|
# Strip the "v0=" prefix — verify_signature must reject.
|
|
assert not verify_signature(
|
|
body=body, timestamp_header=str(ts), signature_header=raw_hex,
|
|
signing_secret=secret,
|
|
)
|
|
|
|
|
|
def test_rejects_empty_inputs() -> None:
|
|
assert not verify_signature(
|
|
body=b"x", timestamp_header="", signature_header="v0=abc",
|
|
signing_secret="s",
|
|
)
|
|
assert not verify_signature(
|
|
body=b"x", timestamp_header="123", signature_header="",
|
|
signing_secret="s",
|
|
)
|
|
assert not verify_signature(
|
|
body=b"x", timestamp_header="123", signature_header="v0=abc",
|
|
signing_secret="",
|
|
)
|
|
|
|
|
|
def test_rejects_non_integer_timestamp() -> None:
|
|
assert not verify_signature(
|
|
body=b"x", timestamp_header="not-an-int",
|
|
signature_header="v0=abc", signing_secret="s",
|
|
)
|