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>
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""License guard for the tool CLIs.
|
|
|
|
Every tool CLI (``cli.py``, ``cli_text_clean.py``, ``cli_format.py``,
|
|
``cli_missing.py``, ``cli_column_map.py``, ``cli_pipeline.py``,
|
|
``cli_analyze.py``) calls :func:`guard` from its ``main()`` before
|
|
delegating to Typer. The guard:
|
|
|
|
1. Lets ``--help`` / ``-h`` through unconditionally so users can
|
|
always see what a command does.
|
|
2. Lets the ``DATATOOLS_DEV_MODE=1`` env var bypass the check.
|
|
3. Otherwise, verifies a valid license is on disk. If not, prints a
|
|
one-line user-facing message naming the exact ``datatools-license``
|
|
subcommand to run, then exits with status 2.
|
|
|
|
The exit code (2) is the same Typer uses for argument errors — fits
|
|
into ``run_tests.py`` / CI pipelines without special casing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from typing import NoReturn
|
|
|
|
|
|
_HELP_FLAGS = {"--help", "-h", "--version"}
|
|
|
|
|
|
def _is_help_invocation() -> bool:
|
|
"""True when the user is asking for help, not running work."""
|
|
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
|
|
|
|
|
|
def guard(feature: str | None = None) -> None:
|
|
"""Block startup if no valid license.
|
|
|
|
*feature* — when supplied, also requires that the active license's
|
|
tier unlocks the named feature flag (e.g.
|
|
``"03_format_standardizer"``). A Lite-tier user running
|
|
``cli_format`` would pass the global validity check but fail the
|
|
feature check; we surface a clear "upgrade your tier" message
|
|
rather than letting them hit a runtime error halfway through a
|
|
job.
|
|
|
|
No-op when license is valid (and the feature is unlocked), when
|
|
called with ``--help`` / ``-h`` / ``--version``, or under
|
|
``DATATOOLS_DEV_MODE=1``.
|
|
"""
|
|
if _is_help_invocation():
|
|
return
|
|
# Lazy import so a broken license module doesn't fail ``--help``.
|
|
from src.license import (
|
|
ExpiredLicenseError,
|
|
InvalidLicenseError,
|
|
LicenseError,
|
|
UnsupportedFeatureError,
|
|
assert_production_safe,
|
|
get_manager,
|
|
)
|
|
|
|
# Refuse to run a misconfigured shipped build. No-op in
|
|
# development / pytest runs.
|
|
assert_production_safe()
|
|
|
|
mgr = get_manager()
|
|
if mgr.dev_mode:
|
|
return
|
|
|
|
try:
|
|
if not mgr.is_valid():
|
|
state = mgr.current_state()
|
|
_exit_with_message(state)
|
|
if feature is not None:
|
|
try:
|
|
mgr.require_feature(feature)
|
|
except UnsupportedFeatureError as e:
|
|
_exit_with_feature_message(feature, str(e))
|
|
return
|
|
except LicenseError:
|
|
state = mgr.current_state()
|
|
_exit_with_message(state)
|
|
|
|
|
|
def _exit_with_feature_message(feature: str, detail: str) -> NoReturn:
|
|
"""Print the upgrade-tier diagnostic and exit. Mirrors the
|
|
GUI's ``require_feature_or_render_upgrade`` UX in CLI form."""
|
|
msg = (
|
|
f"This command requires the {feature!r} feature, which is not "
|
|
f"included in your current license tier.\n"
|
|
f"Detail: {detail}\n"
|
|
"Run ``python -m src.license_cli status`` to see your tier, "
|
|
"then activate an upgrade blob with "
|
|
"``python -m src.license_cli renew <blob>``."
|
|
)
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
|
|
def _exit_with_message(state) -> NoReturn:
|
|
"""Print the right one-liner for the current state and exit."""
|
|
if state.error_kind == "not_activated":
|
|
msg = (
|
|
"DataTools is not activated.\n"
|
|
"Run: python -m src.license_cli activate <license-blob>\n"
|
|
"Or start a 1-year trial: "
|
|
"python -m src.license_cli trial --name 'Your Name' --email you@example.com"
|
|
)
|
|
elif state.error_kind == "expired":
|
|
msg = (
|
|
f"License expired on {state.expires_at[:10]}.\n"
|
|
"Renew with: python -m src.license_cli renew <license-blob>"
|
|
)
|
|
elif state.error_kind == "invalid":
|
|
msg = (
|
|
"License file is present but invalid.\n"
|
|
f"Detail: {state.error_message}\n"
|
|
"Re-paste the blob from your delivery email: "
|
|
"python -m src.license_cli activate <license-blob>"
|
|
)
|
|
else:
|
|
msg = "License is not valid. Run: python -m src.license_cli status"
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
raise SystemExit(2)
|