Files
datatools-dev/src/cli_license_guard.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

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)