"""HMAC-signed webhook delivery to the SHOC receiver (contract sections 6-7). Owns the signed POST and its response classification. The signing key comes from Secrets Manager (``workorder-ingest/shoc-webhook-hmac``, dual-key shape ``{"keys": [{"kid", "secret"}, ...]}``), cached in the warm container for a short TTL so 30-day rotation propagates without waiting for the execution environment to recycle -- the producer always signs with ``keys[0]``. Response contract (section 7): 2xx -> delivered; 429/5xx/timeout/connection error -> RetryableDeliveryError (the handler surfaces it as a batch item failure so the ESM blocks the shard and retries in order); any other status -> ("rejected", code) for the handler to park (a contract bug must not block the shard for 24 hours). Secret material and signatures are never logged. ``sign_body`` is a module-level pure function on purpose: the golden-vector test suite and the replay script both pin against it, and it is the shared definition Luby's receiver verifies with. """ import hashlib import hmac import json import logging import os import time import urllib.error import urllib.request from http import HTTPStatus import boto3 logger = logging.getLogger() logger.setLevel(logging.INFO) # Endpoint path is TBD by SHOC; the activation PR confirms the final URL. SHOC_WEBHOOK_URL = os.environ.get( "SHOC_WEBHOOK_URL", "https://api.dev.seahaven.com/api/webhooks/work-orders" ) HMAC_SECRET_ARN = os.environ.get("HMAC_SECRET_ARN") POST_TIMEOUT_SECONDS = 10 USER_AGENT = "workorder-shoc-emitter/1" # 2xx = delivered; 429 and 5xx retry; everything else parks (contract sec. 7). _HTTP_SUCCESS_RANGE = range(HTTPStatus.OK, HTTPStatus.MULTIPLE_CHOICES) _HTTP_SERVER_ERROR_MIN = HTTPStatus.INTERNAL_SERVER_ERROR # Refresh the cached key material this often so a rotated secret propagates # without waiting for the execution environment to recycle (contract requires # receiver-side TTL <= 300s; the producer matches it). _HMAC_KEYS_CACHE_TTL_SECONDS = 300 _hmac_keys_cache = None _hmac_keys_cached_at = 0.0 class RetryableDeliveryError(Exception): """Delivery failed in a way the ESM should retry in order (shard-blocking). ``status_code`` carries the HTTP status when one exists (429/5xx); it is None for timeouts, connection errors, and secret-fetch failures. """ def __init__(self, message: str, status_code: int | None = None): super().__init__(message) self.status_code = status_code def _get_hmac_keys() -> list[dict]: """Fetch the signing-key list from Secrets Manager, TTL-cached. An empty/missing ``keys`` list is the bootstrap state before the first rotation has run -- retryable, not a crash: the shard blocks until the rotator populates the secret. Fetch failures (throttle, transient IAM/KMS denial) are likewise retryable so a Secrets Manager blip blocks in order instead of failing the whole batch. Key material is never logged. """ global _hmac_keys_cache, _hmac_keys_cached_at now = time.monotonic() if ( _hmac_keys_cache is not None and now - _hmac_keys_cached_at < _HMAC_KEYS_CACHE_TTL_SECONDS ): return _hmac_keys_cache secrets = boto3.client("secretsmanager") try: secret = secrets.get_secret_value(SecretId=HMAC_SECRET_ARN) keys = json.loads(secret["SecretString"]).get("keys") or [] except Exception as exc: raise RetryableDeliveryError(f"hmac secret fetch failed: {exc}") from exc if not keys: raise RetryableDeliveryError("hmac secret not yet rotated") _hmac_keys_cache = keys _hmac_keys_cached_at = now return keys def sign_body(secret_hex: str, timestamp: int, raw_body: bytes) -> str: """HMAC-SHA256 hex digest over ``f"{timestamp}.{raw_body}"`` (raw bytes). The key is the UTF-8 bytes of the secret string exactly as stored in the secret's ``secret`` field (the receiver reads the same JSON field -- no hex-decoding on either side). Pure function; the golden-vector tests and Luby's receiver both pin against this definition. """ string_to_sign = f"{timestamp}.".encode() + raw_body return hmac.new( secret_hex.encode("utf-8"), string_to_sign, hashlib.sha256 ).hexdigest() def deliver(envelope: dict) -> tuple[str, int]: """POST one envelope to SHOC. Returns ("delivered"|"rejected", status). Raises RetryableDeliveryError for 429/5xx/timeout/connection failures so the caller can block the shard (in-order retry, contract section 7). """ if not SHOC_WEBHOOK_URL.startswith("https://"): # Fail closed on any non-HTTPS scheme (file://, http://, ...): the # URL is operator-set env config, but urllib would happily follow # other schemes and the HMAC only protects an HTTPS transport. raise RetryableDeliveryError( "SHOC_WEBHOOK_URL is not an https:// URL; refusing to deliver" ) raw_body = json.dumps(envelope).encode("utf-8") timestamp = int(time.time()) signing_key = _get_hmac_keys()[0] signature = sign_body(signing_key["secret"], timestamp, raw_body) request = urllib.request.Request( SHOC_WEBHOOK_URL, data=raw_body, headers={ "Content-Type": "application/json; charset=utf-8", "User-Agent": USER_AGENT, "X-SH-Timestamp": str(timestamp), "X-SH-Key-Id": signing_key["kid"], "X-SH-Signature": f"v1={signature}", }, method="POST", ) try: with urllib.request.urlopen(request, timeout=POST_TIMEOUT_SECONDS) as response: status_code = response.status except urllib.error.HTTPError as exc: status_code = exc.code if ( status_code == HTTPStatus.TOO_MANY_REQUESTS or status_code >= _HTTP_SERVER_ERROR_MIN ): raise RetryableDeliveryError( f"receiver returned {status_code}", status_code=status_code ) from exc return ("rejected", status_code) except (TimeoutError, urllib.error.URLError, OSError) as exc: raise RetryableDeliveryError(f"connection error: {exc}") from exc if status_code in _HTTP_SUCCESS_RANGE: return ("delivered", status_code) # A non-2xx that urlopen did not raise for (e.g. an unfollowed 3xx): # contract bug on someone's side -- park it, never block the shard. return ("rejected", status_code)