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:
@@ -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('/')}>← 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 — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user