Add licence UI and vendor signing tool

Admin screen shows the machine fingerprint to send for a licence key,
installs a pasted key, and lists connected stations. A persistent banner
warns all users during the grace period.

The browser sends the station heartbeat rather than the bridge, because
the bridge deliberately has no dependency on Django; it already talks to
both. Two tabs on one station share a slot, and a station reconnecting
at the limit reclaims its own slot so a brief drop cannot lock it out
behind its own expiring registration.

license_tool.py is the only thing that can sign a licence - the
application can only verify. The private key it generates is gitignored
and must never ship: if it leaks every licence becomes forgeable, and it
cannot be rotated without reissuing to every customer, since the public
key is compiled into shipped binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:58:10 +03:00
parent 5a8527e96e
commit 6fe9f1820c
4 changed files with 683 additions and 0 deletions
@@ -0,0 +1,266 @@
.license-manager {
min-height: 100vh;
background-color: #f5f5f7;
}
.lm-body {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.lm-loading,
.lm-empty {
padding: 24px;
text-align: center;
color: #999;
font-style: italic;
}
.lm-toolbar {
margin-bottom: 16px;
}
.lm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
cursor: pointer;
font-size: 13px;
}
.lm-back:hover {
border-color: #667eea;
color: #667eea;
}
.lm-ok,
.lm-error {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 12px;
}
.lm-ok {
background-color: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
.lm-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
}
.lm-panel {
background: white;
border-radius: 8px;
padding: 18px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.lm-panel h3 {
margin: 0 0 12px 0;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lm-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.lm-badge--ok {
background-color: #e8f5e9;
color: #2e7d32;
}
.lm-badge--warn {
background-color: #fff3cd;
color: #856404;
}
.lm-facts {
display: grid;
grid-template-columns: 140px 1fr;
gap: 6px 12px;
margin: 0;
}
.lm-facts dt {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: center;
}
.lm-facts dd {
margin: 0;
font-size: 14px;
color: #333;
}
.lm-reason {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
}
.lm-grace {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-hint {
margin: 0 0 12px 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-fingerprint {
display: flex;
align-items: center;
gap: 8px;
}
.lm-fingerprint code {
flex: 1;
padding: 10px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
letter-spacing: 1px;
color: #333;
word-break: break-all;
}
.lm-components {
font-size: 11px !important;
letter-spacing: 0 !important;
}
.lm-fingerprint button {
padding: 8px 14px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
.lm-fingerprint button:hover {
border-color: #667eea;
color: #667eea;
}
.lm-details {
margin-top: 10px;
}
.lm-details summary {
font-size: 12px;
color: #888;
cursor: pointer;
margin-bottom: 8px;
}
.lm-blob {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
box-sizing: border-box;
resize: vertical;
}
.lm-install {
margin-top: 10px;
padding: 9px 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.lm-install:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.lm-install:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lm-table {
width: 100%;
border-collapse: collapse;
}
.lm-table th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
text-transform: uppercase;
letter-spacing: 0.3px;
background-color: #f8f9fa;
}
.lm-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
font-family: 'Courier New', monospace;
}
/* Persistent, non-dismissible banner shown app-wide during grace */
.license-banner {
padding: 8px 16px;
background-color: #fff3cd;
border-bottom: 1px solid #ffe69c;
color: #856404;
font-size: 13px;
text-align: center;
}
.license-banner strong {
font-weight: 600;
}
.license-banner--blocked {
background-color: #fee;
border-bottom-color: #fcc;
color: #c33;
}
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import { useLicense } from '../../contexts/LicenseContext';
import api from '../../services/api';
import './LicenseManager.css';
export default function LicenseManager() {
const navigate = useNavigate();
const { status, refreshStatus } = useLicense();
const [blob, setBlob] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState('');
const copy = (text, label) => {
navigator.clipboard?.writeText(text).then(
() => { setCopied(label); setTimeout(() => setCopied(''), 2000); },
() => setError('Could not copy - select and copy manually')
);
};
const handleInstall = async () => {
if (!blob.trim()) return;
setSaving(true); setError(''); setMessage('');
try {
await api.post('/api/license/', { key_blob: blob.trim() });
await refreshStatus();
setBlob('');
setMessage('Licence installed.');
} catch (err) {
setError(err.response?.data?.detail || 'Could not install licence');
} finally {
setSaving(false);
}
};
if (!status) {
return (
<div className="license-manager">
<Header />
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
}
const componentsJson = JSON.stringify(status.components || {});
return (
<div className="license-manager">
<Header />
<div className="lm-body">
<div className="lm-toolbar">
<button className="lm-back" onClick={() => navigate('/')}>&larr; Back</button>
</div>
{message && <div className="lm-ok">{message}</div>}
{error && <div className="lm-error">{error}</div>}
{/* Current state */}
<div className="lm-panel">
<h3>Licence</h3>
{status.valid ? (
<div className="lm-state lm-state--ok">
<div className="lm-badge lm-badge--ok">Licensed</div>
<dl className="lm-facts">
<dt>Customer</dt><dd>{status.customer || '—'}</dd>
<dt>Stations</dt>
<dd>{status.stations_used} of {status.stations} in use</dd>
</dl>
</div>
) : (
<div className="lm-state lm-state--warn">
<div className="lm-badge lm-badge--warn">
{status.in_grace ? 'Not licensed' : 'Licence expired'}
</div>
<p className="lm-reason">{status.reason}</p>
{status.in_grace ? (
<p className="lm-grace">
Everything keeps working for <strong>{status.grace_days_left} more days</strong>.
After that only {1} station can measure. Send the fingerprint below to
your supplier to get a licence key.
</p>
) : (
<p className="lm-grace">
The grace period has ended, so only {status.stations} station can measure.
Send the fingerprint below to your supplier.
</p>
)}
</div>
)}
</div>
{/* Fingerprint to send */}
<div className="lm-panel">
<h3>This machine</h3>
<p className="lm-hint">
Send this to your supplier to obtain a licence key. It identifies this
server only &mdash; the values are one-way hashes, not hardware serial numbers.
</p>
<div className="lm-fingerprint">
<code>{status.fingerprint}</code>
<button onClick={() => copy(status.fingerprint, 'fingerprint')}>
{copied === 'fingerprint' ? 'Copied' : 'Copy'}
</button>
</div>
<details className="lm-details">
<summary>Full details (needed to issue the key)</summary>
<div className="lm-fingerprint">
<code className="lm-components">{componentsJson}</code>
<button onClick={() => copy(componentsJson, 'components')}>
{copied === 'components' ? 'Copied' : 'Copy'}
</button>
</div>
</details>
</div>
{/* Install */}
<div className="lm-panel">
<h3>Install a licence key</h3>
<textarea
className="lm-blob"
value={blob}
onChange={e => setBlob(e.target.value)}
placeholder="Paste the licence key from your supplier"
rows={5}
/>
<button className="lm-install" onClick={handleInstall} disabled={saving || !blob.trim()}>
{saving ? 'Checking...' : 'Install licence'}
</button>
</div>
{/* Connected stations */}
{status.slots && (
<div className="lm-panel">
<h3>Connected stations ({status.slots.length})</h3>
{status.slots.length === 0 ? (
<div className="lm-empty">No stations have reported in.</div>
) : (
<table className="lm-table">
<thead>
<tr>
<th>Station</th><th>COM port</th><th>Bridge version</th><th>Last seen</th>
</tr>
</thead>
<tbody>
{status.slots.map(s => (
<tr key={s.station_key}>
<td>{s.station_key}</td>
<td>{s.com_port || '—'}</td>
<td>{s.bridge_version || '—'}</td>
<td>{new Date(s.last_seen).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import api from '../services/api';
const LicenseContext = createContext(null);
const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats
export const useLicense = () => {
const ctx = useContext(LicenseContext);
if (!ctx) throw new Error('useLicense must be used within LicenseProvider');
return ctx;
};
/** Stable identifier for the bridge this browser is using.
*
* The bridge deliberately never talks to Django, so the browser reports which
* station it is on. Built from the bridge address plus its COM port, so two
* browsers on the same station share one slot rather than consuming two.
*/
async function readStationKey() {
try {
const res = await fetch(`${SERIAL_URL}/status`);
if (!res.ok) return null;
const status = await res.json();
const raw = `${SERIAL_URL}|${status.com_port || 'unknown'}`;
// Short stable hash - this is an identifier, not a secret
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
return {
station_key: `st-${Math.abs(hash).toString(36)}`,
com_port: status.com_port || '',
bridge_version: status.version || '',
};
} catch {
return null;
}
}
export function LicenseProvider({ children }) {
const [state, setState] = useState({
allowed: true, // assume allowed until told otherwise, so a slow
stationsUsed: null, // first heartbeat never blocks a working station
stationsLicensed: null,
detail: null,
stationKey: null,
});
const [status, setStatus] = useState(null);
const stationRef = useRef(null);
const beat = useCallback(async () => {
if (!stationRef.current) {
stationRef.current = await readStationKey();
}
const station = stationRef.current;
if (!station) return; // bridge unreachable; nothing to register
try {
const res = await api.post('/api/license/heartbeat/', station);
setState({
allowed: res.data.allowed,
stationsUsed: res.data.stations_used,
stationsLicensed: res.data.stations_licensed,
detail: null,
stationKey: station.station_key,
});
} catch (err) {
if (err.response?.status === 403) {
const d = err.response.data || {};
setState({
allowed: false,
stationsUsed: d.stations_used ?? null,
stationsLicensed: d.stations_licensed ?? null,
detail: d.detail || 'Licence limit reached',
stationKey: station.station_key,
});
}
// Other failures (server down) leave the previous state alone -
// a licence check must not stop a working weighbridge.
}
}, []);
useEffect(() => {
beat();
const timer = setInterval(beat, HEARTBEAT_MS);
return () => clearInterval(timer);
}, [beat]);
const loadStatus = useCallback(async () => {
try {
const res = await api.get('/api/license/');
setStatus(res.data);
return res.data;
} catch {
return null;
}
}, []);
useEffect(() => { loadStatus(); }, [loadStatus]);
return (
<LicenseContext.Provider value={{ ...state, status, refreshStatus: loadStatus }}>
{children}
</LicenseContext.Provider>
);
}
+141
View File
@@ -0,0 +1,141 @@
"""Vendor licence signing tool - KEEP THIS AND THE PRIVATE KEY OUT OF CUSTOMER BUILDS.
This is the only thing that can create a valid licence. The application can only
verify. If the private key leaks, every licence ever issued becomes forgeable and
it cannot be rotated without reissuing to every customer, because the public key
is compiled into shipped binaries.
Usage:
Create your keypair once:
python license_tool.py keygen
Then for each customer, from the fingerprint and components they send you:
python license_tool.py sign --customer "Port of Varna" --stations 5 \\
--components '{"board":"ab12...","disk":"cd34...","mac":"ef56...","guid":"78ab..."}'
The customer reads their component hashes from the admin licence screen (there is
a copy button) - they are hashes, not raw serial numbers, so they carry no
identifying hardware detail.
"""
import argparse
import base64
import json
import os
import sys
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PublicKey,
)
from cryptography.hazmat.primitives import serialization
KEY_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'license_private.key')
def keygen(args):
if os.path.exists(KEY_PATH) and not args.force:
print(f"Refusing to overwrite {KEY_PATH}")
print("Every licence signed with the existing key would stop verifying.")
print("Pass --force only if you are certain.")
return 1
private_key = Ed25519PrivateKey.generate()
raw = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
with open(KEY_PATH, 'wb') as fh:
fh.write(base64.b64encode(raw))
public_raw = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
public_b64 = base64.b64encode(public_raw).decode()
print(f"Private key written to {KEY_PATH}")
print(" Back this up somewhere safe. It cannot be recovered.")
print(" Never commit it, never ship it.\n")
print("Put this public key in backend/api/licensing.py as LICENSE_PUBLIC_KEY:\n")
print(f"LICENSE_PUBLIC_KEY = '{public_b64}'")
return 0
def _load_private_key():
if not os.path.exists(KEY_PATH):
print(f"No private key at {KEY_PATH} - run 'keygen' first.")
sys.exit(1)
with open(KEY_PATH, 'rb') as fh:
return Ed25519PrivateKey.from_private_bytes(base64.b64decode(fh.read()))
def sign(args):
private_key = _load_private_key()
try:
components = json.loads(args.components)
except json.JSONDecodeError as exc:
print(f"--components is not valid JSON: {exc}")
return 1
known = {'board', 'disk', 'mac', 'guid'}
unknown = set(components) - known
if unknown:
print(f"Unknown component keys: {sorted(unknown)} (expected {sorted(known)})")
return 1
present = [k for k, v in components.items() if v]
if len(present) < 3:
print(f"Only {len(present)} components supplied; at least 3 must match on the")
print("customer machine, so signing this licence would lock them out.")
return 1
payload = {
'customer': args.customer,
'stations': args.stations,
'components': components,
}
if args.expires:
payload['expires'] = args.expires
payload_json = json.dumps(payload, sort_keys=True, separators=(',', ':'))
signature = private_key.sign(payload_json.encode('utf-8'))
blob = base64.b64encode(
f"{payload_json}.{base64.b64encode(signature).decode()}".encode('utf-8')
).decode()
print(f"Licence for {args.customer}, {args.stations} station(s)")
print(f"Components supplied: {', '.join(sorted(present))}")
if args.expires:
print(f"Expires: {args.expires}")
else:
print("Perpetual (no expiry)")
print("\nSend this to the customer:\n")
print(blob)
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
p_keygen = sub.add_parser('keygen', help='Create the vendor keypair (once)')
p_keygen.add_argument('--force', action='store_true')
p_keygen.set_defaults(func=keygen)
p_sign = sub.add_parser('sign', help='Sign a licence for a customer machine')
p_sign.add_argument('--customer', required=True)
p_sign.add_argument('--stations', type=int, required=True)
p_sign.add_argument('--components', required=True,
help='JSON of hashed components from the admin screen')
p_sign.add_argument('--expires', default=None,
help='ISO date, e.g. 2027-01-01T00:00:00+00:00 (omit for perpetual)')
p_sign.set_defaults(func=sign)
args = parser.parse_args()
sys.exit(args.func(args))
if __name__ == '__main__':
main()