Files
datatools-dev/scripts/generate_keypair.py
Michael e534fb4989 sec(license): Ed25519 sigs + production-safe tripwire
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>
2026-05-13 17:34:48 +00:00

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