6fe9f1820c
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>
142 lines
4.9 KiB
Python
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()
|