Plans record the decisions behind each feature and, for the protocol and licensing work, what remains unvalidated against real hardware. ~WRL0003.tmp was a Word autosave artifact committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
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
expiresfield 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.
- Server computes a machine fingerprint from stable hardware IDs
- Customer sends that string (email, phone, photo — it is short)
- Vendor runs
tools/license_tool.py sign, which signs{customer, stations, components}with the Ed25519 private key - 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("<payload json>.<base64 signature>").
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
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
- Genuine licence verifies; customer and station count read back correctly
- Tampering rejected — editing
"stations":5to"stations":500in a real signed blob breaks the signature. This is the property the entire scheme rests on. - Licence for a different machine rejected (1 of 4 match, 3 required)
- 3-of-4 tolerance works — one component changed (simulated disk replacement) still validates
- Empty, garbage and malformed blobs rejected cleanly with distinct messages
- Expired licence rejected
End to end — 10 checks
- No licence → grace period, 14 days, still usable
- Install a 5-station licence → customer and count correct
- Five stations connect →
used=5 licensed=5 - Sixth declined → 403, "Licence limit reached (5 stations)"
- The five keep working (refresh succeeds)
- Reconnect at the limit reclaims its own slot → 200, not locked out
- Offline station frees its slot after timeout → sixth then admitted
measurewith an unregistered station key → 403measurefrom a licensed station → 200- 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