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:
@@ -35,9 +35,9 @@ from src.license import (
|
||||
UnsupportedFeatureError,
|
||||
)
|
||||
from src.license.crypto import (
|
||||
_DEFAULT_SECRET,
|
||||
decode_blob,
|
||||
encode_blob,
|
||||
is_using_dev_key,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
@@ -138,13 +138,26 @@ class TestSignAndVerify:
|
||||
bad = sig[:-1] + ("0" if sig[-1] != "0" else "1")
|
||||
assert verify(payload, bad) is False
|
||||
|
||||
def test_sign_respects_secret_env_override(self, monkeypatch):
|
||||
def test_sign_respects_privkey_env_override(self, monkeypatch):
|
||||
# Use a different valid Ed25519 private key (32 bytes hex).
|
||||
# Picked arbitrarily; doesn't need to match the dev key.
|
||||
alt_priv = "00" * 32
|
||||
payload = {"a": 1}
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_SECRET", "alternate")
|
||||
alt = sign(payload)
|
||||
monkeypatch.delenv("DATATOOLS_LICENSE_SECRET", raising=False)
|
||||
default = sign(payload)
|
||||
assert alt != default
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_PRIVKEY", alt_priv)
|
||||
alt_sig = sign(payload)
|
||||
monkeypatch.delenv("DATATOOLS_LICENSE_PRIVKEY", raising=False)
|
||||
default_sig = sign(payload)
|
||||
assert alt_sig != default_sig
|
||||
|
||||
def test_verify_with_wrong_pubkey_returns_false(self, monkeypatch):
|
||||
# Sign with the dev key (default), then swap the pubkey and
|
||||
# confirm verification fails.
|
||||
payload = {"a": 1}
|
||||
sig = sign(payload)
|
||||
# 32-byte hex that isn't the matching dev pubkey.
|
||||
wrong_pub = "11" * 32
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_PUBKEY", wrong_pub)
|
||||
assert verify(payload, sig) is False
|
||||
|
||||
def test_canonical_form_is_key_order_invariant(self):
|
||||
a = {"x": 1, "y": 2}
|
||||
@@ -159,17 +172,26 @@ class TestBlobEncodeDecode:
|
||||
again = decode_blob(blob)
|
||||
assert again == payload
|
||||
|
||||
def test_blob_has_human_readable_prefix(self):
|
||||
def test_blob_uses_v2_prefix(self):
|
||||
"""v1.6 switched HMAC → Ed25519; blob version bumped to DTLIC2.
|
||||
Pin the prefix so any future scheme change is intentional."""
|
||||
blob = encode_blob({"x": 1})
|
||||
assert blob.startswith("DTLIC1:")
|
||||
assert blob.startswith("DTLIC2:")
|
||||
|
||||
def test_decode_rejects_missing_prefix(self):
|
||||
with pytest.raises(ValueError, match="DTLIC1"):
|
||||
with pytest.raises(ValueError, match="DTLIC2"):
|
||||
decode_blob("not-a-blob")
|
||||
|
||||
def test_decode_rejects_v1_blob_with_clear_message(self):
|
||||
"""A v1 (HMAC) blob must surface a clear 'old format' message
|
||||
rather than 'signature mismatch' — buyers redeeming an old
|
||||
delivery email need to know to request a new blob."""
|
||||
with pytest.raises(ValueError, match="DTLIC1"):
|
||||
decode_blob("DTLIC1:eyJhIjogMX0=")
|
||||
|
||||
def test_decode_rejects_bad_base64(self):
|
||||
with pytest.raises(ValueError, match="base64"):
|
||||
decode_blob("DTLIC1:!!!notbase64!!!")
|
||||
decode_blob("DTLIC2:!!!notbase64!!!")
|
||||
|
||||
def test_decode_rejects_truncated_blob(self):
|
||||
blob = encode_blob({"x": 1})
|
||||
@@ -178,6 +200,72 @@ class TestBlobEncodeDecode:
|
||||
decode_blob(truncated)
|
||||
|
||||
|
||||
class TestDevKeypair:
|
||||
"""The embedded dev keypair must match the seed phrase so anyone
|
||||
reproducing the build gets the same values. Catches a hand-edit
|
||||
to ``_dev_keypair.py`` that drifts the constants from the seed."""
|
||||
|
||||
def test_dev_keypair_matches_seed(self):
|
||||
from src.license._dev_keypair import (
|
||||
DEV_PRIVATE_KEY_HEX,
|
||||
DEV_PUBLIC_KEY_HEX,
|
||||
_derive_from_seed,
|
||||
)
|
||||
derived_priv, derived_pub = _derive_from_seed()
|
||||
assert derived_priv == DEV_PRIVATE_KEY_HEX
|
||||
assert derived_pub == DEV_PUBLIC_KEY_HEX
|
||||
|
||||
def test_is_using_dev_key_true_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("DATATOOLS_LICENSE_PUBKEY", raising=False)
|
||||
assert is_using_dev_key() is True
|
||||
|
||||
def test_is_using_dev_key_false_when_overridden(self, monkeypatch):
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_PUBKEY", "22" * 32)
|
||||
assert is_using_dev_key() is False
|
||||
|
||||
|
||||
class TestProductionSafe:
|
||||
"""``assert_production_safe`` is a tripwire that fires only in
|
||||
frozen / shipped builds. Tests simulate the frozen state via
|
||||
monkeypatching ``sys.frozen``."""
|
||||
|
||||
def test_no_op_in_source_run(self):
|
||||
# Default test run: sys.frozen is unset; nothing should raise.
|
||||
from src.license import assert_production_safe
|
||||
assert_production_safe() # no exception
|
||||
|
||||
def test_raises_on_dev_mode_in_frozen_build(self, monkeypatch):
|
||||
from src.license import (
|
||||
ProductionBuildError,
|
||||
assert_production_safe,
|
||||
)
|
||||
monkeypatch.setattr("sys.frozen", True, raising=False)
|
||||
monkeypatch.setenv("DATATOOLS_DEV_MODE", "1")
|
||||
# Override pubkey so the dev-key check doesn't fire first.
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_PUBKEY", "22" * 32)
|
||||
with pytest.raises(ProductionBuildError, match="DATATOOLS_DEV_MODE"):
|
||||
assert_production_safe()
|
||||
|
||||
def test_raises_on_dev_key_in_frozen_build(self, monkeypatch):
|
||||
from src.license import (
|
||||
ProductionBuildError,
|
||||
assert_production_safe,
|
||||
)
|
||||
monkeypatch.setattr("sys.frozen", True, raising=False)
|
||||
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
|
||||
monkeypatch.delenv("DATATOOLS_LICENSE_PUBKEY", raising=False)
|
||||
with pytest.raises(ProductionBuildError, match="development license key"):
|
||||
assert_production_safe()
|
||||
|
||||
def test_passes_in_frozen_build_with_prod_pubkey(self, monkeypatch):
|
||||
from src.license import assert_production_safe
|
||||
monkeypatch.setattr("sys.frozen", True, raising=False)
|
||||
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
|
||||
monkeypatch.setenv("DATATOOLS_LICENSE_PUBKEY", "22" * 32)
|
||||
# Should not raise.
|
||||
assert_production_safe()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Features
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user