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

69 lines
2.0 KiB
Python

"""License module — registration, activation, expiration, feature gating.
Public API the rest of the app uses:
- :func:`get_manager` — singleton :class:`LicenseManager` instance.
- :func:`current_state` — quick snapshot for status badges / tests.
- :func:`require_feature` — raise :class:`LicenseError` if a feature
isn't unlocked by the active license.
- :class:`License`, :class:`Tier`, :class:`FeatureFlag` — schema.
- :class:`LicenseError` and subclasses — typed failures the UI can
branch on (not yet activated vs. expired vs. tampered).
The license model is:
1. The seller (creator) runs ``scripts/generate_license.py`` to mint a
signed **license blob** keyed to a buyer's name + email.
2. The buyer pastes the blob into the activation page on first launch.
3. The app verifies the HMAC signature locally (no internet), then
writes a canonical ``~/.datatools/license.json`` and the app
unlocks.
The signature is HMAC-SHA256 with a build-time secret. Combined with
the 30-day refund policy, this is honor-system DRM — see
``docs/DECISIONS.md`` for the trade-off discussion.
"""
from __future__ import annotations
from .errors import (
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
NotActivatedError,
UnsupportedFeatureError,
)
from .features import FEATURES_BY_TIER, all_features_for_tier
from .manager import (
LicenseManager,
ProductionBuildError,
assert_production_safe,
current_state,
get_manager,
require_feature,
)
from .schema import FeatureFlag, License, Tier
__all__ = [
# Manager
"LicenseManager",
"ProductionBuildError",
"assert_production_safe",
"current_state",
"get_manager",
"require_feature",
# Schema
"FeatureFlag",
"License",
"Tier",
# Feature registry
"FEATURES_BY_TIER",
"all_features_for_tier",
# Errors
"LicenseError",
"NotActivatedError",
"ExpiredLicenseError",
"InvalidLicenseError",
"UnsupportedFeatureError",
]