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,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