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:
@@ -6,6 +6,7 @@ constructor for full isolation.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
assert_production_safe() # guard against build-config errors
|
||||
mgr = get_manager()
|
||||
if not mgr.is_activated():
|
||||
mgr.activate_from_blob(blob, name, email)
|
||||
@@ -17,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -468,3 +470,69 @@ def current_state() -> LicenseState:
|
||||
|
||||
def require_feature(feature: str | FeatureFlag) -> License:
|
||||
return get_manager().require_feature(feature)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production-build sanity check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProductionBuildError(RuntimeError):
|
||||
"""Raised when a frozen / shipped build is misconfigured in a way
|
||||
that would defeat licensing. Always loud, always fatal — the
|
||||
binary must not boot in this state."""
|
||||
|
||||
|
||||
def _is_shipped_build() -> bool:
|
||||
"""True when running from a PyInstaller bundle (``sys.frozen``).
|
||||
|
||||
Set automatically by PyInstaller; not set in source / pytest
|
||||
runs. The whole purpose of the prod-safe check is to enforce
|
||||
invariants that only matter in a shipped build, so the rest of
|
||||
the codebase can stay flexible.
|
||||
"""
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def assert_production_safe() -> None:
|
||||
"""Fail loudly if a shipped build is misconfigured.
|
||||
|
||||
Two tripwires:
|
||||
|
||||
1. ``DATATOOLS_DEV_MODE`` is set in a frozen build. The dev-mode
|
||||
env var unconditionally bypasses license verification — if a
|
||||
buyer's installer somehow ships it enabled (build pipeline
|
||||
bug, mis-set environment), every license check is a no-op.
|
||||
Refuse to start instead.
|
||||
|
||||
2. The active verification key is still the dev key. The build
|
||||
pipeline is supposed to override
|
||||
``DATATOOLS_LICENSE_PUBKEY`` with the production key; if it
|
||||
didn't, the binary will reject every legitimate license
|
||||
(signed with the prod private key) AND would *accept*
|
||||
anything signed with the dev key (which is checked into the
|
||||
source tree). Refuse to start.
|
||||
|
||||
No-ops in non-frozen runs (development, tests) so the dev key
|
||||
+ dev mode keep working in those contexts. Production builds
|
||||
call this from :func:`src.cli_license_guard.guard` and
|
||||
:func:`src.gui.components.hide_streamlit_chrome`.
|
||||
"""
|
||||
if not _is_shipped_build():
|
||||
return
|
||||
|
||||
if _truthy_env("DATATOOLS_DEV_MODE"):
|
||||
raise ProductionBuildError(
|
||||
"DATATOOLS_DEV_MODE is set in a shipped build. This env "
|
||||
"var disables every license check and must never be set "
|
||||
"on a buyer machine. If you see this message in a release "
|
||||
"build, the install was misconfigured — contact support."
|
||||
)
|
||||
|
||||
if crypto.is_using_dev_key():
|
||||
raise ProductionBuildError(
|
||||
"Shipped build is verifying against the development "
|
||||
"license key. The build pipeline must set "
|
||||
"DATATOOLS_LICENSE_PUBKEY to the production public key "
|
||||
"before packaging. This binary will reject every real "
|
||||
"license blob — re-download from the official channel."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user