"""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 from botocore.config import Config logger = logging.getLogger() logger.setLevel(logging.INFO) # Required, NO default (Open SWE #0): a hardcoded fallback URL would silently # ship production work-order state to that endpoint if the CDK-set env var were # ever dropped. Unset -> deliver() fails closed (retries in order) rather than # defaulting anywhere. The single source of the URL is the Lambda environment # (CDK), confirmed by the activation PR. SHOC_WEBHOOK_URL = os.environ.get("SHOC_WEBHOOK_URL") HMAC_SECRET_ARN = os.environ.get("HMAC_SECRET_ARN") POST_TIMEOUT_SECONDS = 10 USER_AGENT = "workorder-shoc-emitter/1" # Bound the Secrets Manager client's timeouts (Open SWE #16): default botocore # timeouts can run to ~60s, which combined with the 10s POST could exhaust the # Lambda budget and make a slow AWS call re-deliver the whole batch. Fast, few # retries -- the key is 300s-cached so this fetch is rare. _BOTO_CONFIG = Config( connect_timeout=3, read_timeout=5, retries={"max_attempts": 2, "mode": "standard"} ) # 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 # Transient auth failures: a stale cached key mid-rotation, a receiver # secret-fetch blip, or clock skew past the +/-300s window. These are # availability events, not payload contract bugs, so they retry (in order) # after the key cache is dropped -- never park, which would strand the # delivery out of the ordered feed until a manual replay. _HTTP_AUTH_FAILURES = (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): """Refuse to follow receiver redirects. The default opener follows 3xx transparently, which would (a) forward the live X-SH-* auth headers to a receiver-chosen Location and (b) let an http:// Location slip past the https-only guard on the configured URL. We only ever POST to the one configured endpoint; a redirect is a receiver misconfiguration, so surface the 3xx as an HTTPError and let it park. """ def redirect_request(self, *args, **kwargs): return None _opener = urllib.request.build_opener(_NoRedirectHandler) # 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 return _refresh_hmac_keys() def _invalidate_hmac_keys() -> None: """Drop the cached key material so the next sign re-fetches (rotation).""" global _hmac_keys_cache _hmac_keys_cache = None def _refresh_hmac_keys() -> list[dict]: """Force a fresh Secrets Manager fetch, bypassing the TTL cache.""" global _hmac_keys_cache, _hmac_keys_cached_at now = time.monotonic() secrets = boto3.client("secretsmanager", config=_BOTO_CONFIG) 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 or not SHOC_WEBHOOK_URL.startswith("https://"): # Fail closed if the URL is unset (no hardcoded fallback -- Open SWE # #0) or is any non-HTTPS scheme (file://, http://, ...): urllib would # otherwise follow it, and the HMAC only protects an HTTPS transport. # Retryable, so a config gap blocks the shard (visible via iterator-age) # rather than shipping data somewhere unintended. raise RetryableDeliveryError( "SHOC_WEBHOOK_URL is unset or 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: # _opener refuses redirects, so a 3xx surfaces here as an HTTPError # rather than silently re-issuing the request (with its auth headers) # to a receiver-chosen Location. with _opener.open(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 if status_code in _HTTP_AUTH_FAILURES: # Drop the cached key so an in-order retry re-signs with the # current secret (handles a rotation that outran the TTL cache). _invalidate_hmac_keys() raise RetryableDeliveryError( f"receiver auth failure {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 the opener did not raise for -- contract bug on # someone's side. Park it, never block the shard. return ("rejected", status_code)