Two coupled hardening upgrades. 1. Asymmetric signatures (HMAC → Ed25519) The previous HMAC scheme used a symmetric secret that any motivated reverse engineer could pull out of the shipped binary and use to mint blobs for any tier / name / email. With Ed25519, the binary ships only the public verification key; the signing key never leaves the seller's environment, so binary compromise no longer yields forgery. - src/license/crypto.py rewritten around cryptography.hazmat.primitives.asymmetric.ed25519. Same public API surface (sign/verify/encode_blob/decode_blob), same canonical JSON encoding — drop-in for the manager / cli / GUI layers. - DATATOOLS_LICENSE_PRIVKEY (seller-side) and DATATOOLS_LICENSE_PUBKEY (build-time) env vars supply the keys; the in-source dev keypair (src/license/_dev_keypair.py) deterministically derives from a seed phrase for repro builds and tests. - Blob prefix bumped DTLIC1: → DTLIC2:. Decoding a DTLIC1 blob surfaces a clear "old format" error rather than a confusing signature mismatch. - scripts/generate_keypair.py mints fresh production keypairs for the seller (run once, stash the private key offline). Adds cryptography>=41,<46 to requirements.txt (was an undeclared transitive dep). 2. Production-safe tripwire assert_production_safe() refuses to boot a frozen / shipped build when either: - DATATOOLS_DEV_MODE=1 is set (would unconditionally bypass every license check — fine in source/test but catastrophic in a buyer install). - The active verification key is still the embedded dev key (the build pipeline forgot to set DATATOOLS_LICENSE_PUBKEY). No-op in source / pytest runs (sys.frozen is unset) so test fixtures and dev workflows keep working without ceremony. Called from src/cli_license_guard.guard() and from hide_streamlit_chrome — so it fires on every CLI invocation and every GUI page load. Tests: 49 license-layer unit tests (was 40); added Ed25519 wrong-key rejection, dev-keypair seed pin, blob v2 prefix, v1 rejection with clear message, and four production-safe scenarios (no-op in source, fires on DEV_MODE in frozen, fires on dev key in frozen, passes in frozen with prod pubkey). Total: 2024 → 2033. Docs (REQUIREMENTS §17a, DEVELOPER licensing recipe, DECISIONS §9b + decision log) updated with the new threat-model write-up, key-storage workflow, and tripwire behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a fresh Ed25519 keypair for production license signing.
|
|
|
|
**Creator-only.** Run once, write the private key somewhere safe,
|
|
configure the build pipeline with the public key.
|
|
|
|
Usage::
|
|
|
|
python scripts/generate_keypair.py
|
|
python scripts/generate_keypair.py --json
|
|
python scripts/generate_keypair.py --output keys.txt
|
|
|
|
The output looks like::
|
|
|
|
DATATOOLS_LICENSE_PRIVKEY=<64 hex chars> # KEEP SECRET
|
|
DATATOOLS_LICENSE_PUBKEY=<64 hex chars> # BAKE INTO BUILD
|
|
|
|
The private key never goes near the buyer-facing binary. Stash it in
|
|
a password manager / KMS / hardware token; the only places it gets
|
|
loaded are:
|
|
|
|
- ``scripts/generate_license.py`` when minting a buyer's blob
|
|
- Your CI's signing step, if you've automated blob minting
|
|
|
|
The public key gets set as ``DATATOOLS_LICENSE_PUBKEY`` in the
|
|
PyInstaller build env (so the shipped binary verifies against it),
|
|
and the production-safe runtime check refuses to start any frozen
|
|
build that's still using the in-source dev key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
|
|
|
def generate() -> tuple[str, str]:
|
|
"""Return ``(private_hex, public_hex)`` for a fresh keypair."""
|
|
priv = Ed25519PrivateKey.generate()
|
|
priv_hex = priv.private_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PrivateFormat.Raw,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
).hex()
|
|
pub_hex = priv.public_key().public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw,
|
|
).hex()
|
|
return priv_hex, pub_hex
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
p.add_argument("--json", action="store_true", help="Emit JSON instead of env-file format.")
|
|
p.add_argument("--output", "-o", type=Path, default=None, help="Write to this file instead of stdout.")
|
|
args = p.parse_args(argv)
|
|
|
|
priv_hex, pub_hex = generate()
|
|
|
|
if args.json:
|
|
payload = json.dumps(
|
|
{"private_key": priv_hex, "public_key": pub_hex},
|
|
indent=2,
|
|
)
|
|
else:
|
|
payload = (
|
|
f"# DataTools license keypair — generated by generate_keypair.py\n"
|
|
f"# KEEP THE PRIVATE KEY SECRET. Lose it and your existing\n"
|
|
f"# licenses can't be renewed (you'd have to ship a new build\n"
|
|
f"# with a new public key and re-issue every active license).\n"
|
|
f"\n"
|
|
f"DATATOOLS_LICENSE_PRIVKEY={priv_hex}\n"
|
|
f"DATATOOLS_LICENSE_PUBKEY={pub_hex}\n"
|
|
)
|
|
|
|
if args.output:
|
|
args.output.write_text(payload + "\n", encoding="utf-8")
|
|
# chmod 600 — best-effort; ignored on Windows.
|
|
try:
|
|
args.output.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
print(f"Wrote {args.output} (mode 600)", file=sys.stderr)
|
|
else:
|
|
print(payload)
|
|
|
|
print(
|
|
"\nNext steps:\n"
|
|
" 1. Store the private key in your password manager.\n"
|
|
" 2. Bake the public key into the PyInstaller build:\n"
|
|
" DATATOOLS_LICENSE_PUBKEY=<pubkey> pyinstaller ...\n"
|
|
" 3. Mint buyer licenses by setting the private key:\n"
|
|
" DATATOOLS_LICENSE_PRIVKEY=<privkey> "
|
|
"python scripts/generate_license.py --name 'Buyer' --email b@x.com\n",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|