hermes-agent/tests/gateway/relay/test_inbound_receiver.py
Ben Barclay c276b017ad
feat(relay): connector⇄gateway channel auth + signed-HTTP inbound receiver + enroll CLI (#48147)
* feat(relay): authenticate the connector⇄gateway WS channel

The relay gateway may be customer-managed and internet-exposed, so the
connector⇄gateway channel is itself authenticated (distinct from the
platform crypto the relay path sheds). Add gateway/relay/auth.py — a
Python port of the connector's HMAC token + delivery-signature schemes
(relayAuthToken.ts / deliverySigning.ts), verified byte-for-byte against
the connector's compiled TypeScript via cross-language test vectors.

Present an Authorization bearer on the /relay WS upgrade keyed by the
per-gateway secret (resolved from GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET
in env or config). The connector rejects an unauthenticated/invalid/
revoked upgrade with close 4401.

* feat(relay): signed-HTTP inbound delivery receiver

The connector delivers normalized inbound events to a tenant's gateway
over a signed HTTP POST, not the outbound /relay WS: the connector
instance owning a platform socket is generally not the instance a given
gateway dialed out to, so inbound targets a tenant endpoint that may
load-balance across gateway instances.

Add gateway/relay/inbound_receiver.py — verifies x-relay-signature /
x-relay-timestamp over the EXACT raw request bytes (re-serializing would
break the HMAC: JS JSON.stringify is compact, Python json.dumps spaces)
against the per-tenant delivery key verify list within a 300s replay
window, then dispatches messages to handle_message and interrupts to the
interrupt handler. Wire it into the adapter lifecycle (start in connect()
when a delivery key + bind port are configured, tear down in disconnect();
a purely-outbound dev gateway runs without it).

Refine test_relay_sheds_crypto to distinguish PLATFORM crypto (Discord
ed25519, Twilio/WeCom HMAC — still shed) from the connector⇄gateway
CHANNEL auth (intended): auth.py / inbound_receiver.py are exempt from
the platform-symbol scan but still banned from importing platform-crypto
modules, plus a positive guard that auth.py uses only stdlib hmac/hashlib.

* feat(relay): hermes gateway enroll CLI

Add the gateway half of zero-touch enrollment. `hermes gateway enroll`
resolves a fresh Nous Portal access token (the tenant-proving identity),
POSTs {enrollmentToken, gatewayId} to the connector's /relay/enroll, and
persists GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY
to ~/.hermes/.env. The per-gateway secret authenticates the WS upgrade;
the per-tenant delivery key verifies signed inbound deliveries.

Refuses under is_managed() (hosted installs get the secret stamped in by
the orchestrator). Added as an 'enroll' subcommand on the existing
gateway subparser — not a new top-level command.

* docs(relay): inbound is signed HTTP, not WS; document channel auth

Fix the stale contract: §3/§5 said inbound rode the WS socket (single-
instance only, predates the multi-instance socket-ownership + channel-auth
model). Inbound + connector→gateway interrupt are signed HTTP POSTs to the
tenant endpoint. Add §6.1 documenting the two channel-auth schemes (per-
gateway WS-upgrade secret, per-tenant inbound delivery key) and how they
differ from the platform crypto the relay path sheds.

* test(relay): update build_gateway_parser callers for cmd_gateway_enroll

The enroll subcommand added cmd_gateway_enroll as a required keyword-only
arg to build_gateway_parser, but two existing parser-extraction tests still
called it with only cmd_gateway/cmd_proxy — failing CI with TypeError.
Thread the new handler through both call sites and add a test asserting
`gateway enroll` dispatches to cmd_gateway_enroll with its flags parsed.
2026-06-18 12:01:54 +10:00

150 lines
5.5 KiB
Python

"""Unit tests for gateway/relay/inbound_receiver.py.
Covers the verify-then-dispatch core (handle_raw): a correctly-signed message
delivery is verified + dispatched; an interrupt delivery routes to the interrupt
handler; unsigned/tampered/expired/no-key deliveries are rejected 401; malformed
JSON is 400. Signatures are produced with the SAME auth primitives the connector
uses (gateway/relay/auth.py sign), so this exercises the real verify path.
"""
from __future__ import annotations
import json
import time
import pytest
from gateway.relay.auth import sign
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
def _signed(body_obj: dict, key: str = _KEY, ts: int | None = None) -> tuple[bytes, str, str]:
"""Serialize compactly (as the connector's JSON.stringify does), sign it."""
body = json.dumps(body_obj, separators=(",", ":"))
raw = body.encode("utf-8")
t = ts if ts is not None else int(time.time())
return raw, str(t), sign(f"{t}.{body}", key)
def _receiver(**kw):
received: list = []
interrupts: list = []
async def on_message(ev):
received.append(ev)
async def on_interrupt(sk, chat):
interrupts.append((sk, chat))
r = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [_KEY],
on_message=on_message,
on_interrupt=on_interrupt,
**kw,
)
return r, received, interrupts
@pytest.mark.asyncio
async def test_valid_message_delivery_dispatched():
r, received, _ = _receiver()
raw, ts, sig = _signed(
{
"type": "message",
"event": {
"text": "hello",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
},
}
)
status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 200 and body == {"ok": True}
assert len(received) == 1
assert received[0].text == "hello"
assert received[0].source.guild_id == "guildA"
@pytest.mark.asyncio
async def test_valid_interrupt_delivery_routes_to_interrupt_handler():
r, _, interrupts = _receiver()
raw, ts, sig = _signed({"type": "interrupt", "session_key": "agent:main:discord:group:c:u", "reason": "stop"})
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=True)
assert status == 200
assert interrupts and interrupts[0][0] == "agent:main:discord:group:c:u"
@pytest.mark.asyncio
async def test_tampered_body_rejected_401():
r, received, _ = _receiver()
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw + b" ", timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
assert received == []
@pytest.mark.asyncio
async def test_unsigned_rejected_401():
r, _, _ = _receiver()
raw, _, _ = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw, timestamp=None, signature=None, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_expired_timestamp_rejected_401():
r, _, _ = _receiver(max_skew_seconds=300)
raw, _, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, ts=1)
# ts=1 (1970) is far outside the 300s window vs now.
status, _ = await r.handle_raw(raw_body=raw, timestamp="1", signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_wrong_key_rejected_401():
r, _, _ = _receiver()
other = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=other)
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_no_delivery_key_fails_closed_401():
async def on_message(ev):
pass
r = InboundDeliveryReceiver(delivery_key_verify_list=lambda: [], on_message=on_message)
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_rotation_secondary_key_accepted():
new = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
received: list = []
async def on_message(ev):
received.append(ev)
# Connector still signs with the OLD key (secondary); verify list has both.
r = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [new, _KEY], on_message=on_message
)
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=_KEY)
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 200 and len(received) == 1
@pytest.mark.asyncio
async def test_malformed_json_after_valid_signature_is_400():
r, _, _ = _receiver()
# Sign a non-JSON body so the signature passes but json.loads fails.
raw = b"not json at all"
ts = str(int(time.time()))
sig = sign(f"{ts}.{raw.decode()}", _KEY)
status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 400