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

186 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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