feat(backend): offline-capable signed licensing; fix expiry grace; state in data dir (KI-006)

- services/license.py: verifies the cloud's Ed25519-signed license token
  (public key built in) and decides purely: valid → licensed until expiry,
  then a 5-day grace, then blocked - never mid-service (deferred while a
  workday is open, applied at close). Works offline for as long as the
  license lasts: the "unlicensed after 72h without heartbeat" rule is gone.
- Tamper resistance: an edited token fails the signature ("unverified");
  a clock earlier than the latest provable time (token issued_at, newest
  order in the DB, stored high-water mark; 1 day tolerance) → "clock".
- apply_license() re-evaluates from the stored token at startup, after
  every heartbeat attempt and when a workday closes. Cloud lock/unlock from
  the token keeps the workday-deferred behaviour. Transition: a cloud
  without tokens is trusted 72h per successful heartbeat.
- FIX: the promised 5-day grace after expiry never happened - the cloud's
  licensed=false was applied immediately (402 on everything).
- FIX: license_state.json lived inside the container and was lost on every
  re-creation; it now lives in the data volume (old path read once).
- /api/system/status: offline_days, license_verified, license_problem,
  grace_over; lock_reason "clock"/"unverified"; grace days from the license
  module (rounded up).

Tests: 18 unit checks (signature, tamper, other site/key, 364 days
offline, grace ±workday, inactive, clock rollback, transition) + 17 E2E
checks with a real cloud + site process (400 days offline, tampered file →
402, clock behind newest order, expiry deferred until workday close,
renewal, remote lock/unlock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:16:32 +03:00
co-authored by Claude Opus 5.5
parent a349043abb
commit 13a451a756
4 changed files with 228 additions and 114 deletions
+4 -2
View File
@@ -168,8 +168,10 @@ def close_business_day(
if license_state.get("lock_pending"):
license_state["lock_pending"] = False
license_state["locked"] = True
from services.cloud_sync import _persist_state
_persist_state()
# Re-evaluate the license now that no workday is open (an expiry whose grace
# ended mid-service takes effect here) — also persists the state
from services.cloud_sync import apply_license
apply_license()
return day
+25 -25
View File
@@ -60,9 +60,23 @@ def identity(db: Session = Depends(get_db)):
}
def _lock_reason() -> str | None:
"""Why the site is (or will be) blocked, for the manager's banner:
"admin" (locked/lock pending) · "clock" (system clock set back) ·
"unverified" (no genuine license yet) · "expired" (expiry grace over /
site deactivated) · None."""
if license_state.get("locked") or license_state.get("lock_pending"):
return "admin"
problem = license_state.get("license_problem")
if problem in ("clock", "unverified"):
return problem
if problem in ("expired", "inactive") or not license_state.get("licensed", True):
return "expired"
return None
@router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from datetime import datetime, timezone
printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_statuses = []
for p in printers:
@@ -78,26 +92,10 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
days_until_expiry = license_state.get("days_until_expiry")
grace_expires_at = license_state.get("grace_expires_at")
# Determine lock_reason for the frontend banner logic
# "admin" — locked by sysadmin (immediately or deferred)
# "expired" — license grace period over, site is blocked
# None — all good
lock_reason = None
if locked or lock_pending:
lock_reason = "admin"
elif not licensed:
lock_reason = "expired"
lock_reason = _lock_reason()
# Grace days remaining (only meaningful while in expiry grace period)
grace_days_remaining = None
if grace_expires_at:
try:
grace_dt = datetime.fromisoformat(grace_expires_at)
if grace_dt.tzinfo is None:
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
except ValueError:
pass
# Computed by services/license.py (only set during the expiry grace period)
grace_days_remaining = license_state.get("grace_days_remaining")
return {
"uptime_seconds": int(time.time() - _start_time),
@@ -113,6 +111,12 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
"grace_days_remaining": grace_days_remaining,
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
# Offline licensing (KI-006): days without cloud contact; the signed license
# keeps the site running until expiry regardless
"offline_days": license_state.get("offline_days"),
"license_verified": license_state.get("license_verified", False),
"license_problem": license_state.get("license_problem"),
"grace_over": license_state.get("grace_over", False),
"waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None,
"lan_ip": lan.get("effective"),
@@ -155,11 +159,7 @@ async def sync_license_now(user: User = Depends(require_manager)):
"licensed": license_state.get("licensed", True),
"locked": license_state.get("locked", False),
"lock_pending": license_state.get("lock_pending", False),
"lock_reason": (
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
else "expired" if not license_state.get("licensed", True)
else None
),
"lock_reason": _lock_reason(),
"expires_at": license_state.get("expires_at"),
"days_until_expiry": license_state.get("days_until_expiry"),
"sync_failed": license_state.get("sync_failed", False),
+89 -83
View File
@@ -1,28 +1,28 @@
"""
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
Lock behaviour:
- cloud sets locked=true → set lock_pending=true in state
- lock_pending is enforced at workday-close time (see business_day router)
- while a workday is open, the site keeps running; lock applies once it closes
Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a
license, not to RUN one. Each heartbeat returns a signed license token; the
site stores it and enforces it offline — until expiry (+5 days grace), however
long it has no internet. apply_license() re-evaluates the stored token without
network: at startup, after every heartbeat attempt and when a workday closes.
License expiry behaviour:
- 5 days before expiry → warning only (days_until_expiry in state)
- on expiry → 5-day grace period begins (grace_expires_at in state)
- after grace + no open workday → licensed=False enforced by business_day router
Lock behaviour (unchanged):
- cloud sets locked=true → lock_pending while a workday is open, locked once it closes
- expiry past grace / site deactivated → likewise never mid-service
"""
import asyncio
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
import httpx
from config import settings
from middleware.license_check import license_state
from services.license import evaluate, parse_dt, verify_token
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
@@ -30,20 +30,30 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
GRACE_HOURS = 72 # 3 days offline grace
EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking
EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning
STATE_FILE = Path(__file__).parent.parent / "license_state.json"
def _data_dir() -> Path:
"""Directory of the SQLite database — the persistent data volume in Docker."""
from services.tls_identity import tls_dir
return tls_dir().parent
# In the data volume: it must survive container re-creation (image updates,
# compose changes), or an offline site would lose its license. Earlier versions
# kept it inside the container at local_backend/license_state.json.
STATE_FILE = _data_dir() / "license_state.json"
LEGACY_STATE_FILE = Path(__file__).parent.parent / "license_state.json"
def _load_persisted_state():
if STATE_FILE.exists():
for path in (STATE_FILE, LEGACY_STATE_FILE):
if path.exists():
try:
data = json.loads(STATE_FILE.read_text())
license_state.update(data)
logger.info("Loaded persisted license state: %s", data)
license_state.update(json.loads(path.read_text()))
logger.info("Loaded persisted license state from %s", path)
return
except Exception as e:
logger.warning("Could not load license state file: %s", e)
logger.warning("Could not load license state file %s: %s", path, e)
def _persist_state():
@@ -53,29 +63,54 @@ def _persist_state():
logger.warning("Could not persist license state: %s", e)
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
if not expires_at_str:
return {"days_until_expiry": None, "grace_expires_at": None}
def _latest_activity(db) -> datetime | None:
"""Newest order timestamp in the database — the clock can't be set before it."""
from sqlalchemy import func
from models.order import Order, OrderItem
stamps = [db.query(func.max(Order.opened_at)).scalar(), db.query(func.max(OrderItem.added_at)).scalar()]
stamps = [parse_dt(x) for x in stamps if x]
return max(stamps, default=None)
def apply_license(now: datetime | None = None) -> None:
"""Re-evaluate the license from the stored signed token (no network)."""
if not settings.SITE_ID:
return # dev / unregistered install: licensing off, as before
from database import SessionLocal
from models.business_day import BusinessDay
now = now or datetime.now(timezone.utc)
payload = verify_token(license_state.get("license_token"), settings.SITE_ID)
db = SessionLocal()
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except ValueError:
return {"days_until_expiry": None, "grace_expires_at": None}
workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None
latest_activity = _latest_activity(db)
finally:
db.close()
now = datetime.now(timezone.utc)
days_until = (expires_at - now).days # negative once expired
floors = [parse_dt(license_state.get("time_high_water")), latest_activity,
parse_dt(payload["issued_at"]) if payload else None]
floor = max((f for f in floors if f), default=None)
result = evaluate(payload, now, floor, workday_open, parse_dt(license_state.get("legacy_ok_at")))
if result["license_problem"] != "clock":
license_state["time_high_water"] = max(floor or now, now).isoformat()
grace_expires_at = None
if days_until < 0:
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
if payload: # lock requested by the cloud — deferred while a workday is open
if payload.get("locked"):
if workday_open and not license_state.get("locked"):
license_state["lock_pending"] = True
else:
license_state["lock_pending"] = False
license_state["locked"] = True
else:
license_state["lock_pending"] = False
license_state["locked"] = False
return {
"days_until_expiry": days_until,
"grace_expires_at": grace_expires_at,
}
last_sync = parse_dt(license_state.get("last_sync"))
license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None})
_persist_state()
if result["license_problem"]:
logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"])
def _get_local_ip() -> str | None:
@@ -112,69 +147,39 @@ async def _sync_once():
resp.raise_for_status()
data = resp.json()
licensed = data.get("licensed", True)
cloud_locked = data.get("locked", False)
expires_at = data.get("expires_at")
expiry_fields = _compute_expiry_fields(expires_at)
# If cloud says locked, check whether a workday is currently open.
# No open workday → lock immediately.
# Open workday → defer to workday close (business_day router enforces it).
if cloud_locked:
from database import SessionLocal
from models.business_day import BusinessDay
db = SessionLocal()
try:
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
finally:
db.close()
if open_day:
if not license_state.get("lock_pending"):
token = data.get("license_token")
if token:
if verify_token(token, settings.SITE_ID):
license_state["license_token"] = token
else:
logger.error("Cloud sent a license token that does not verify - keeping the previous one")
else:
# Cloud without license signing (transition): trust this answer for 72h,
# keep the old immediate lock handling
if data.get("licensed", True):
license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat()
license_state["expires_at"] = data.get("expires_at")
if data.get("locked"):
license_state["lock_pending"] = True
logger.info("Cloud requested lock — workday open, deferring to workday close")
else:
license_state["lock_pending"] = False
license_state["locked"] = True
logger.info("Cloud requested lock — no open workday, locking immediately")
# If cloud lifts the lock, clear pending too
if not cloud_locked:
license_state["lock_pending"] = False
license_state["locked"] = False
license_state.update({
"licensed": licensed,
"expires_at": expires_at,
"latest_version": data.get("latest_version"),
"waiter_domain": data.get("waiter_domain"),
"site_numeric_id": data.get("site_numeric_id"),
"last_sync": datetime.now(timezone.utc).isoformat(),
"sync_failed": False,
**expiry_fields,
})
_persist_state()
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
logger.info("Cloud sync OK (signed license: %s)", bool(token))
except Exception as e:
logger.warning("Cloud sync failed: %s", e)
license_state["sync_failed"] = True
last_sync_str = license_state.get("last_sync")
if last_sync_str:
try:
last_sync = datetime.fromisoformat(last_sync_str)
grace_expires = last_sync + timedelta(hours=GRACE_HOURS)
if datetime.now(timezone.utc) > grace_expires:
logger.error("72-hour offline grace period expired — marking unlicensed")
license_state["licensed"] = False
except ValueError:
pass
# Recompute expiry fields from cached expires_at even when offline
expiry_fields = _compute_expiry_fields(license_state.get("expires_at"))
license_state.update(expiry_fields)
# Online or not: the stored signed license decides (no more 72h offline rule)
apply_license()
IMAGE_DIR = Path("/app/data/product_images")
@@ -397,6 +402,7 @@ async def _pull_pending_orders():
async def _sync_loop():
_load_persisted_state()
apply_license() # decide from the stored license before the first network attempt
while True:
await _sync_once()
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
+106
View File
@@ -0,0 +1,106 @@
"""
Offline-capable licensing (KI-006).
The cloud is needed to RENEW a license, not to RUN one. Every heartbeat
brings a license token signed by the cloud (Ed25519). The site stores it and
enforces it by itself, so a venue that paid for a year keeps working for that
year even if it never goes online again. The old rule ("unlicensed after 72h
without a heartbeat") is gone.
Tamper resistance:
- Editing the stored token breaks the signature → treated as no license.
- Turning the clock back: "now" may not be earlier than the latest time this
system has provably seen — the token's signed cloud time, the newest order
in the database, and a stored high-water mark (1 day tolerance).
Limits (documented): someone with root on the server could still patch the
code itself, and a remote lock only reaches a site when it next goes online.
Rules (evaluate()):
valid token, clock OK:
active and not expired → licensed
expired: 5-day grace → licensed, warnings in the manager
grace over / site deactivated → unlicensed, but never mid-service:
deferred while a workday is open
no valid token:
old cloud without tokens, heartbeat said licensed < 72h ago → licensed (transition)
otherwise → unlicensed ("unverified")
Locks from the cloud (token.locked) keep the existing workday-deferred behaviour.
"""
import base64
import json
import math
from datetime import datetime, timedelta, timezone
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# Matches LICENSE_SIGNING_KEY in the cloud's .env (generated 2026-09-28).
# Built into the code on purpose: a key taken from configuration could simply
# be replaced together with a self-made token.
LICENSE_PUBLIC_KEY = "2oeFHV6hgAlJsx/ZBvG6fqmWYn5tjSW5hURrrPhLoOw="
EXPIRY_GRACE = timedelta(days=5)
CLOCK_TOLERANCE = timedelta(days=1)
LEGACY_UNSIGNED_OK = timedelta(hours=72) # only while the cloud sends no tokens
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def parse_dt(value) -> datetime | None:
if not value:
return None
try:
dt = datetime.fromisoformat(value) if isinstance(value, str) else value
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def verify_token(token: str | None, site_id: str, public_key_b64: str = LICENSE_PUBLIC_KEY) -> dict | None:
"""Payload of a genuine token for this site, else None."""
if not token or "." not in token:
return None
body, _, sig = token.partition(".")
try:
Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64)).verify(_b64url_decode(sig), body.encode())
payload = json.loads(_b64url_decode(body))
except (InvalidSignature, ValueError, TypeError):
return None
if payload.get("v") != 1 or payload.get("site_id") != site_id:
return None
if not parse_dt(payload.get("expires_at")) or not parse_dt(payload.get("issued_at")):
return None
return payload
def evaluate(payload: dict | None, now: datetime, floor: datetime | None, workday_open: bool,
legacy_ok_at: datetime | None = None) -> dict:
"""Pure license decision. `floor` = latest time this system has provably seen."""
base = {"license_verified": payload is not None, "license_problem": None, "grace_over": False,
"days_until_expiry": None, "grace_expires_at": None, "grace_days_remaining": None}
if payload is None:
legacy = legacy_ok_at is not None and now - legacy_ok_at <= LEGACY_UNSIGNED_OK
return {**base, "licensed": legacy, "license_problem": None if legacy else "unverified"}
if floor is not None and now < floor - CLOCK_TOLERANCE:
return {**base, "licensed": False, "license_problem": "clock"}
expires = parse_dt(payload["expires_at"])
grace_end = expires + EXPIRY_GRACE
days_until = (expires - now).days # negative once expired
grace_over = now > grace_end
fields = {
**base,
"expires_at": expires.isoformat(),
"days_until_expiry": days_until,
"grace_expires_at": grace_end.isoformat() if days_until < 0 else None,
# Rounded up: 2 days 23 hours left reads as "3 days", as people count it
"grace_days_remaining": math.ceil((grace_end - now) / timedelta(days=1)) if days_until < 0 and not grace_over else None,
"grace_over": grace_over,
}
problem = "inactive" if not payload.get("active", True) else "expired" if grace_over else None
# Never cut a restaurant off mid-service: an open workday finishes first
return {**fields, "licensed": problem is None or workday_open, "license_problem": problem}