* 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.
150 lines
5.5 KiB
Python
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
|