"""Payload assembly and HMAC-SHA256 signing. The signature covers every payload field *except* ``hmac_signature`` itself, over a canonical ``json.dumps(..., sort_keys=True)`` serialization (spec §6.2), so any tampering with the body invalidates it. """ import hashlib import hmac import json from typing import Optional from compliance.settings import ComplianceSettings # Router release this plugin ships with. Sent as ``router_version`` and in the # ``X-Router-Version`` header. ROUTER_VERSION = "0.9" def _signing_key(cfg: ComplianceSettings) -> str: """HMAC key: the per-tenant override if set, else the API key (spec §6.2).""" return cfg.hmac_secret or cfg.api_key or "" def canonical_message(body: dict) -> str: """Canonical serialization signed by both client and server.""" fields = {k: v for k, v in body.items() if k != "hmac_signature"} return json.dumps(fields, sort_keys=True, separators=(",", ":")) def sign(body: dict, cfg: ComplianceSettings) -> str: """Return the ``sha256=`` signature for ``body``.""" digest = hmac.new( _signing_key(cfg).encode("utf-8"), canonical_message(body).encode("utf-8"), hashlib.sha256, ).hexdigest() return f"sha256={digest}" def build_payload(cfg: ComplianceSettings, snapshot: dict) -> dict: """Combine config identity + collector snapshot into a signed payload.""" body = { "tenant_id": cfg.tenant_id, "router_id": cfg.router_id, "router_version": ROUTER_VERSION, "timestamp": snapshot["timestamp"], "models": snapshot["models"], "endpoints": snapshot["endpoints"], "telemetry": snapshot["telemetry"], } body["hmac_signature"] = sign(body, cfg) return body def build_config_report(cfg: ComplianceSettings) -> dict: """The body posted to ``/config/report`` (spec §5.3).""" return { "polling_interval": cfg.polling_interval, "batch_size": cfg.batch_size, "server_url": cfg.server_url, "router_version": ROUTER_VERSION, } def split_into_batches(payload: dict, batch_size: int, cfg: ComplianceSettings) -> list[dict]: """Split a payload whose ``models`` list exceeds ``batch_size`` into several signed payloads sharing the same timestamp/identity (used on HTTP 413). Each batch is re-signed because its ``models`` slice differs. """ models = payload.get("models", []) if batch_size <= 0 or len(models) <= batch_size: return [payload] batches: list[dict] = [] for i in range(0, len(models), batch_size): chunk = {k: v for k, v in payload.items() if k != "hmac_signature"} chunk["models"] = models[i : i + batch_size] chunk["hmac_signature"] = sign(chunk, cfg) batches.append(chunk) return batches