81 lines
2.8 KiB
Python
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
|