nomyo-router/compliance/payload.py

81 lines
2.8 KiB
Python

"""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=<hex>`` 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