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:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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():
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text())
|
||||
license_state.update(data)
|
||||
logger.info("Loaded persisted license state: %s", data)
|
||||
except Exception as e:
|
||||
logger.warning("Could not load license state file: %s", e)
|
||||
for path in (STATE_FILE, LEGACY_STATE_FILE):
|
||||
if path.exists():
|
||||
try:
|
||||
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: %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"):
|
||||
license_state["lock_pending"] = True
|
||||
logger.info("Cloud requested lock — workday open, deferring to workday close")
|
||||
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
|
||||
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["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)
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user