Files
hermes-agent/gateway/relay/auth.py
T

127 lines
4.4 KiB
Python

"""Gateway-side relay authentication primitives. EXPERIMENTAL.
Gateway half of two HMAC schemes whose wire bytes must match the connector's
TypeScript exactly:
1. **WS upgrade auth** (gateway → connector): ``Authorization: Bearer <token>``
on the ``/relay`` upgrade, ``token = make_upgrade_token(gateway_id, secret)``.
Mirrors ``relayAuthToken.ts`` ``makeToken``: ``base64url(f"{payload}:{exp}:{sig}")``
with ``sig = HMAC_SHA256(f"{payload}:{exp}", secret).hexdigest()``.
2. **Inbound delivery signature** (connector → gateway): ``x-relay-timestamp`` +
``x-relay-signature`` headers, ``sig = HMAC_SHA256(f"{ts}.{body_json}", key)``
over the EXACT body bytes, with a replay-window skew check (``deliverySigning.ts``).
Both verify against a multi-secret list (primary first, then a secondary during
a rotation window) so a rotation doesn't invalidate outstanding tokens.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from typing import Optional, Sequence
# Header names used by the connector's deliverySigning.ts.
DELIVERY_TS_HEADER = "x-relay-timestamp"
DELIVERY_SIG_HEADER = "x-relay-signature"
_DEFAULT_MAX_SKEW_SECONDS = 300 # connector default replay window
_DEFAULT_UPGRADE_TTL_SECONDS = 300 # connector makeUpgradeToken default
def sign(payload: str, secret: str) -> str:
"""HMAC-SHA256 hex digest (UTF-8) — the connector's ``sign``."""
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
def verify_signature(payload: str, sig_hex: str, secrets: Sequence[str]) -> bool:
"""Constant-time check of ``sig_hex`` against ANY of ``secrets`` (rotation window).
Length-mismatched candidates are skipped without a timing leak.
"""
try:
sig_buf = bytes.fromhex(sig_hex)
except (ValueError, TypeError):
return False
if len(sig_buf) == 0:
return False
for secret in secrets:
if not secret:
continue
expected = bytes.fromhex(sign(payload, secret))
if len(expected) != len(sig_buf):
continue
if hmac.compare_digest(sig_buf, expected):
return True
return False
def make_token(payload: str, secret: str, ttl_seconds: int = 0) -> str:
"""``base64url(f"{payload}:{exp}:{sig}")``; ``exp`` unix seconds (0 = never).
base64url is unpadded to match Node's ``Buffer.toString("base64url")``.
"""
exp = int(time.time()) + ttl_seconds if ttl_seconds > 0 else 0
signed = f"{payload}:{exp}"
raw = f"{signed}:{sign(signed, secret)}".encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def make_upgrade_token(
gateway_id: str, secret: str, ttl_seconds: int = _DEFAULT_UPGRADE_TTL_SECONDS
) -> str:
"""WS-upgrade bearer: ``payload = gateway_id`` (the connector peeks it to index its verify list)."""
return make_token(gateway_id, secret, ttl_seconds)
def verify_token(token: str, secrets: Sequence[str]) -> Optional[str]:
"""Verify a ``make_token`` token; return the payload or None.
Splits from the right so a payload may itself contain colons.
"""
try:
padded = token + "=" * (-len(token) % 4)
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
except (ValueError, TypeError):
return None
parts = decoded.split(":")
if len(parts) < 3:
return None
sig = parts[-1]
try:
exp = int(parts[-2])
except ValueError:
return None
payload = ":".join(parts[:-2])
if exp != 0 and int(time.time()) > exp:
return None
return payload if verify_signature(f"{payload}:{exp}", sig, secrets) else None
def verify_delivery_signature(
body_json: str,
timestamp: Optional[str],
signature: Optional[str],
verify_keys: Sequence[str],
max_skew_seconds: int = _DEFAULT_MAX_SKEW_SECONDS,
*,
now: Optional[int] = None,
) -> bool:
"""Verify a connector→gateway inbound delivery signature.
``body_json`` MUST be the exact request body bytes decoded as UTF-8 (no
re-serialization — the connector signed the literal body).
"""
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
current = now if now is not None else int(time.time())
if abs(current - ts) > max_skew_seconds:
return False
return verify_signature(f"{ts}.{body_json}", signature, verify_keys)