Files
gitea 6fe9f1820c 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>
2026-09-18 18:58:10 +03:00

142 lines
4.9 KiB
Python

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