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>
This commit is contained in:
106
scripts/generate_keypair.py
Normal file
106
scripts/generate_keypair.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user