Files
gitea fe9fb71860 Add implementation plans and remove stray Word temp file
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>
2026-09-18 18:58:38 +03:00

10 KiB
Raw Permalink Blame History

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("<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

  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