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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user