# Licensing: Server-Side Node-Locked with Station Limit **Status: BUILT and verified, 2026-09-17.** This document is the record of what exists, not a proposal. ## Context Customers buy ScalesApp for a fixed number of weighbridge stations. Without enforcement, nothing stops a customer copying the installer onto more machines. Sites are **air-gapped** (no internet on the scales server), so any online activation or phone-home scheme is impossible. This licences the **server**, enforcing a maximum number of concurrently connected stations. Bridges themselves stay unlicensed. **Decisions made with the user:** - **Licence the server, not each bridge** — one fingerprint, one key per customer, instead of N of each. - **Count = concurrently connected bridges.** Licence says 5; a 6th is declined. If one of the 5 stops, its slot frees automatically. - **Enforcement is per-station**: the 6th is refused, the working 5 are never interrupted. - **Tolerant fingerprint + 14-day grace period** on hardware change. - **Perpetual** — no expiry date (the signed payload carries an optional `expires` field so subscriptions can be added later without changing the format). - Slot refresh every 30s, released after 2 minutes (4 missed refreshes). --- ## Threat model — stated plainly Client-side licensing **can always be defeated** by someone determined; the server is a Python app and bytecode is inspectable. The goal is not "unbreakable": - Honest customers stay honest; the limit is a clear contractual line - Casual copying (operator copies the folder to 3 more PCs) is stopped - A determined pirate with a cracker is **not** stopped For B2B sold to a port with a support contract, that is the right level. Do not invest in obfuscation — it raises effort, never to infinity, and costs debuggability. --- ## How it works ### Signing, not a shared secret The critical design point: **the application only ever verifies; it can never generate.** 1. Server computes a **machine fingerprint** from stable hardware IDs 2. Customer sends that string (email, phone, photo — it is short) 3. Vendor runs `tools/license_tool.py sign`, which signs `{customer, stations, components}` with the **Ed25519 private key** 4. Customer pastes the key back; the server verifies with the **public key** compiled into `api/licensing.py` A shared-secret or same-algorithm-both-ways scheme would be worthless — anyone who unpacks the app could mint keys. With signatures, forging requires the private key, which never leaves the vendor's machine. Blob format: `base64(".")`. ### Tolerant fingerprint Four components are collected and hashed **separately** (not combined), so partial matching is possible: motherboard serial, disk serial, first physical MAC, machine GUID. Validation passes when **at least 3 of 4** match (`MIN_COMPONENT_MATCHES`). A replaced disk or NIC keeps working; a wholly different PC fails. **Windows uses PowerShell CIM, not `wmic`.** This matters: `wmic` is deprecated and **has been removed from recent Windows 11 builds**, where it fails silently and yields nothing. On this development machine the original `wmic` approach found only **1 of 4** components, which would have made every licence fail to validate. Each lookup falls back to `wmic` so older machines still work. Linux reads `/sys/class/dmi`, `lsblk`, `/sys/class/net` and `/etc/machine-id`. Missing components return `None` rather than raising — a VM or restricted environment may not expose all four, and the 3-of-4 rule tolerates that. ### Grace period If validation fails (fewer than 3 components match, or no licence at all), the server enters a **14-day grace period**: everything keeps working, a persistent non-dismissible banner appears for all users, and the admin licence screen explains it. After 14 days the limit drops to **1 station** (`UNLICENSED_STATIONS`). Rationale: a licence check must never stop a weighbridge at 3am. Grace turns a hard outage into a support call. Grace start is stored in the **database**, not a file — a file is trivially reset, and the DB is included in backups. --- ## Counting connected stations The bridge **deliberately never talks to Django** — that independence is a design decision (see `bridge-is-transport-only` in project memory). Licensing does not change it. **The browser reports instead.** It already talks to both. On load and every 30s it reads the bridge's `/status` and POSTs a heartbeat: ``` POST /api/license/heartbeat/ {"station_key": "st-abc123", "com_port": "COM3", "bridge_version": "1.1.0"} -> 200 {"allowed": true, "stations_used": 3, "stations_licensed": 5} -> 403 {"allowed": false, "detail": "Licence limit reached (5 stations)"} ``` `station_key` is a short stable hash of the bridge address plus its COM port, so **two browser tabs on the same station share one slot** rather than consuming two. `StationSlot` rows are live if `last_seen` is within `SLOT_TIMEOUT_SECONDS` (120). **A reconnecting station reclaims its own slot even at the limit** — matched on `station_key` in `_station_allowed()`. This is the single most important rule in the counting logic: without it, a station that blipped would be locked out by its own expiring ghost, which would look exactly like a licensing bug at the worst possible moment. ### What "declined" means The 6th station's Measure button is disabled with the reason shown beneath it, and `POST /api/vehicles/{id}/measure/` returns 403 for that station key. Viewing and editing continue normally. The other five are entirely unaffected. **The API check is the rule; the button state is only convenience.** Enforcement lives in `VehicleViewSet.measure`, not the UI. --- ## What was built ### Backend | File | Contents | |---|---| | `api/licensing.py` | `machine_components()`, `machine_fingerprint()`, `verify_license()`, `license_status()`, `effective_station_limit()`, `LICENSE_PUBLIC_KEY` | | `api/models.py` | `License` (single row, enforced in `save()`), `StationSlot` | | `api/views.py` | `LicenseView` (GET status / POST install), `LicenseHeartbeatView`, `_station_allowed()`, licence check in `measure` | | `api/urls.py` | `license/`, `license/heartbeat/` | | `api/migrations/0008_license_stationslot.py` | generated | | `requirements.txt` | `cryptography==50.0.1` added (PyJWT alone only does HMAC — a shared secret, i.e. the worthless scheme) | ### Vendor tooling — `tools/license_tool.py` ```bash python tools/license_tool.py keygen # once; refuses to overwrite without --force python tools/license_tool.py sign --customer "Port of Varna" --stations 5 \ --components '{"board":"...","disk":"...","mac":"...","guid":"..."}' ``` `sign` refuses fewer than 3 components, since such a licence could never validate on the customer machine. ### Frontend | File | Contents | |---|---| | `contexts/LicenseContext.jsx` | 30s heartbeat, derives `station_key` from the bridge's `/status` | | `components/License/LicenseManager.jsx` + `.css` | Admin screen: state, fingerprint with copy buttons, paste box, connected-station table. 🔑 in header | | `components/Main.jsx` | Measure gated on `licenseAllowed`, `station_key` sent with both measure calls, grace banner | | `App.jsx` | `LicenseProvider`, `/license` route (admin only) | Design note: `LicenseContext` **assumes allowed until told otherwise**, so a slow first heartbeat never blocks a working station. Non-403 failures (server down) leave the previous state alone — a licence check must not stop a working weighbridge. --- ## Security: the private key **`tools/license_private.key`** — gitignored via `*license*.key`, generated 2026-09-17. Verified not tracked. - **If it leaks**, every licence ever issued becomes forgeable - **It cannot be rotated** without reissuing to every customer, since the public key is compiled into shipped binaries - **If it is lost**, no new licences can ever be signed for existing installs Back it up outside the repo. Never ship it. The public key in `api/licensing.py` is safe to commit and must be correct in the build before shipping. --- ## Verification (all passing) ### Crypto — 6 checks 1. Genuine licence verifies; customer and station count read back correctly 2. **Tampering rejected** — editing `"stations":5` to `"stations":500` in a real signed blob breaks the signature. *This is the property the entire scheme rests on.* 3. Licence for a different machine rejected (1 of 4 match, 3 required) 4. **3-of-4 tolerance works** — one component changed (simulated disk replacement) still validates 5. Empty, garbage and malformed blobs rejected cleanly with distinct messages 6. Expired licence rejected ### End to end — 10 checks 1. No licence → grace period, 14 days, still usable 2. Install a 5-station licence → customer and count correct 3. Five stations connect → `used=5 licensed=5` 4. **Sixth declined** → 403, "Licence limit reached (5 stations)" 5. The five keep working (refresh succeeds) 6. **Reconnect at the limit reclaims its own slot** → 200, not locked out 7. Offline station frees its slot after timeout → sixth then admitted 8. `measure` with an unregistered station key → 403 9. `measure` from a licensed station → 200 10. Grace expiry (backdated 15 days) → limit drops to 1 Frontend builds clean; no warnings in any licensing file. --- ## Operating notes **Currently in grace mode** — no licence installed, so everything works and the banner shows. Install one from the 🔑 screen to test the licensed path. **To issue a licence:** customer opens 🔑 → copies the component JSON from "Full details" → sends it → run `license_tool.py sign` → customer pastes the result into the install box. The fingerprint shown large (e.g. `C261-A181-3D8D-AC94`) is for reading over the phone to confirm identity. The component JSON underneath is what `sign` actually needs. Both are one-way hashes, so they carry no identifying hardware detail. --- ## Out of scope - **Expiry / subscriptions** — perpetual only; the payload field exists so this can be added without reissuing the format - **Licensing individual bridges** — rejected as N× the administration - **Obfuscation / anti-tamper** — see the threat model - **Named Station entities** — slots are identified by bridge hash; a Station model was deliberately skipped