hermes-agent/tests/plugins/platforms/photon/test_signature.py
Teknium 5b4e431e8c feat(gateway): add Photon Spectrum (iMessage) platform plugin
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.
2026-06-08 13:38:30 -07:00

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",
)