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"): if license_state.get("lock_pending"):
license_state["lock_pending"] = False license_state["lock_pending"] = False
license_state["locked"] = True license_state["locked"] = True
from services.cloud_sync import _persist_state # Re-evaluate the license now that no workday is open (an expiry whose grace
_persist_state() # ended mid-service takes effect here) — also persists the state
from services.cloud_sync import apply_license
apply_license()
return day 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") @router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)): 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() printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_statuses = [] printer_statuses = []
for p in printers: 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") days_until_expiry = license_state.get("days_until_expiry")
grace_expires_at = license_state.get("grace_expires_at") grace_expires_at = license_state.get("grace_expires_at")
# Determine lock_reason for the frontend banner logic lock_reason = _lock_reason()
# "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"
# Grace days remaining (only meaningful while in expiry grace period) # Computed by services/license.py (only set during the expiry grace period)
grace_days_remaining = None grace_days_remaining = license_state.get("grace_days_remaining")
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
return { return {
"uptime_seconds": int(time.time() - _start_time), "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, "grace_days_remaining": grace_days_remaining,
"sync_failed": license_state.get("sync_failed", False), "sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"), "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"), "waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None, "site_id": settings.SITE_ID or None,
"lan_ip": lan.get("effective"), "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), "licensed": license_state.get("licensed", True),
"locked": license_state.get("locked", False), "locked": license_state.get("locked", False),
"lock_pending": license_state.get("lock_pending", False), "lock_pending": license_state.get("lock_pending", False),
"lock_reason": ( "lock_reason": _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
),
"expires_at": license_state.get("expires_at"), "expires_at": license_state.get("expires_at"),
"days_until_expiry": license_state.get("days_until_expiry"), "days_until_expiry": license_state.get("days_until_expiry"),
"sync_failed": license_state.get("sync_failed", False), "sync_failed": license_state.get("sync_failed", False),
+93 -87
View File
@@ -1,28 +1,28 @@
""" """
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task. 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: Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a
- cloud sets locked=true → set lock_pending=true in state license, not to RUN one. Each heartbeat returns a signed license token; the
- lock_pending is enforced at workday-close time (see business_day router) site stores it and enforces it offline — until expiry (+5 days grace), however
- while a workday is open, the site keeps running; lock applies once it closes 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: Lock behaviour (unchanged):
- 5 days before expiry → warning only (days_until_expiry in state) - cloud sets locked=true → lock_pending while a workday is open, locked once it closes
- on expiry → 5-day grace period begins (grace_expires_at in state) - expiry past grace / site deactivated → likewise never mid-service
- after grace + no open workday → licensed=False enforced by business_day router
""" """
import asyncio import asyncio
import json import json
import logging import logging
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import httpx import httpx
from config import settings from config import settings
from middleware.license_check import license_state from middleware.license_check import license_state
from services.license import evaluate, parse_dt, verify_token
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
@@ -30,20 +30,30 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes 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 def _data_dir() -> Path:
STATE_FILE = Path(__file__).parent.parent / "license_state.json" """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(): def _load_persisted_state():
if STATE_FILE.exists(): for path in (STATE_FILE, LEGACY_STATE_FILE):
try: if path.exists():
data = json.loads(STATE_FILE.read_text()) try:
license_state.update(data) license_state.update(json.loads(path.read_text()))
logger.info("Loaded persisted license state: %s", data) logger.info("Loaded persisted license state from %s", path)
except Exception as e: return
logger.warning("Could not load license state file: %s", e) except Exception as e:
logger.warning("Could not load license state file %s: %s", path, e)
def _persist_state(): def _persist_state():
@@ -53,29 +63,54 @@ def _persist_state():
logger.warning("Could not persist license state: %s", e) logger.warning("Could not persist license state: %s", e)
def _compute_expiry_fields(expires_at_str: str | None) -> dict: def _latest_activity(db) -> datetime | None:
"""Return days_until_expiry and grace_expires_at derived from expires_at.""" """Newest order timestamp in the database — the clock can't be set before it."""
if not expires_at_str: from sqlalchemy import func
return {"days_until_expiry": None, "grace_expires_at": None} 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: try:
expires_at = datetime.fromisoformat(expires_at_str) workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None
if expires_at.tzinfo is None: latest_activity = _latest_activity(db)
expires_at = expires_at.replace(tzinfo=timezone.utc) finally:
except ValueError: db.close()
return {"days_until_expiry": None, "grace_expires_at": None}
now = datetime.now(timezone.utc) floors = [parse_dt(license_state.get("time_high_water")), latest_activity,
days_until = (expires_at - now).days # negative once expired 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 payload: # lock requested by the cloud — deferred while a workday is open
if days_until < 0: if payload.get("locked"):
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat() 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 { last_sync = parse_dt(license_state.get("last_sync"))
"days_until_expiry": days_until, license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None})
"grace_expires_at": grace_expires_at, _persist_state()
} if result["license_problem"]:
logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"])
def _get_local_ip() -> str | None: def _get_local_ip() -> str | None:
@@ -112,69 +147,39 @@ async def _sync_once():
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
licensed = data.get("licensed", True) token = data.get("license_token")
cloud_locked = data.get("locked", False) if token:
expires_at = data.get("expires_at") if verify_token(token, settings.SITE_ID):
expiry_fields = _compute_expiry_fields(expires_at) license_state["license_token"] = token
else:
# If cloud says locked, check whether a workday is currently open. logger.error("Cloud sent a license token that does not verify - keeping the previous one")
# No open workday → lock immediately. else:
# Open workday → defer to workday close (business_day router enforces it). # Cloud without license signing (transition): trust this answer for 72h,
if cloud_locked: # keep the old immediate lock handling
from database import SessionLocal if data.get("licensed", True):
from models.business_day import BusinessDay license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat()
db = SessionLocal() license_state["expires_at"] = data.get("expires_at")
try: if data.get("locked"):
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() license_state["lock_pending"] = True
finally:
db.close()
if open_day:
if not license_state.get("lock_pending"):
license_state["lock_pending"] = True
logger.info("Cloud requested lock — workday open, deferring to workday close")
else: else:
license_state["lock_pending"] = False license_state["lock_pending"] = False
license_state["locked"] = True license_state["locked"] = False
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({ license_state.update({
"licensed": licensed,
"expires_at": expires_at,
"latest_version": data.get("latest_version"), "latest_version": data.get("latest_version"),
"waiter_domain": data.get("waiter_domain"), "waiter_domain": data.get("waiter_domain"),
"site_numeric_id": data.get("site_numeric_id"), "site_numeric_id": data.get("site_numeric_id"),
"last_sync": datetime.now(timezone.utc).isoformat(), "last_sync": datetime.now(timezone.utc).isoformat(),
"sync_failed": False, "sync_failed": False,
**expiry_fields,
}) })
_persist_state() logger.info("Cloud sync OK (signed license: %s)", bool(token))
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
except Exception as e: except Exception as e:
logger.warning("Cloud sync failed: %s", e) logger.warning("Cloud sync failed: %s", e)
license_state["sync_failed"] = True license_state["sync_failed"] = True
last_sync_str = license_state.get("last_sync") # Online or not: the stored signed license decides (no more 72h offline rule)
if last_sync_str: apply_license()
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)
IMAGE_DIR = Path("/app/data/product_images") IMAGE_DIR = Path("/app/data/product_images")
@@ -397,6 +402,7 @@ async def _pull_pending_orders():
async def _sync_loop(): async def _sync_loop():
_load_persisted_state() _load_persisted_state()
apply_license() # decide from the stored license before the first network attempt
while True: while True:
await _sync_once() await _sync_once()
await asyncio.sleep(SYNC_INTERVAL_SECONDS) 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}