""" Signed license tokens for on-site servers (client-services KI-006). Every heartbeat answer carries a token the site stores and verifies with the public key built into its code. The site then enforces the license OFFLINE: it keeps running until `expires_at` (+ its grace), however long it is without internet, and nobody can extend it by editing the stored copy. Format: . Payload: { v: 1, site_id, active, locked, lock_reason, expires_at, issued_at } issued_at = the cloud's clock — the site uses it as a floor against clock rollback. Key: LICENSE_SIGNING_KEY in the cloud .env (base64 raw Ed25519 private key, generated once; see docs). Without it, heartbeats carry no token — old-style behaviour — and a warning is logged. """ import base64 import json import logging from datetime import datetime, timezone from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from config import settings logger = logging.getLogger(__name__) _key: Ed25519PrivateKey | None = None _warned = False def _b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def _signing_key() -> Ed25519PrivateKey | None: global _key, _warned if _key is None and settings.LICENSE_SIGNING_KEY: _key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(settings.LICENSE_SIGNING_KEY)) if _key is None and not _warned: logger.warning("LICENSE_SIGNING_KEY not set — heartbeats carry no signed license token") _warned = True return _key def _iso(dt: datetime) -> str: return (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)).astimezone(timezone.utc).isoformat() def issue_license_token(site, now: datetime) -> str | None: key = _signing_key() if key is None: return None payload = { "v": 1, "site_id": site.site_id, "active": bool(site.is_active), "locked": bool(site.is_locked), "lock_reason": site.lock_reason, "expires_at": _iso(site.license_expires_at), "issued_at": _iso(now), } body = _b64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()) return f"{body}.{_b64url(key.sign(body.encode()))}"