From 4f5569d91c9d1b049a531cb28abec7676b445bb6 Mon Sep 17 00:00:00 2001 From: SyncTek <145518101+SyncTekLLC@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:46:32 -0400 Subject: [PATCH 1/2] fix(license): clamp dev-key-signed licenses to trial limits (privesc fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The private dev SIGNING key ships inside the distributed package (public_key.DEV_SIGNING_KEY_HEX) so the client can self-issue offline trials at runtime (license/cli.py::_issue_dev_license). Because the private key is shipped, a dev-key *signature* is forgeable and is NOT a trust boundary. The validator only checked `subject == "dev-trial"` and then read tier/seats/expires_at straight from the attacker-controlled payload, so anyone who unpacks the wheel could sign `subject="dev-trial", tier="enterprise", seats=999, expires_at=` and obtain unlimited enterprise access. Confirmed by probe: pre-fix the forged payload validated as tier=enterprise seats=999. Fix (load-bearing control = the validator, not the signature): - validator._enforce_dev_trial_limits() hard-rejects (fail closed, license_invalid) any dev-key license that is not a genuine trial: subject must be "dev-trial", tier must be "trial" (paid tiers refused loudly, not downgraded), seats <= trial cap (4 = trial max_simulators), and expires_at within 30 days of effective-now (legit trial is 14d; anchored to now, not the attacker-controlled issued_at). The prod-key path is untouched. - Correct the misleading docstrings in validator.py / public_key.py that claimed the signature alone prevented forgery. - Add tests/test_license_dev_key_clamp.py: forged enterprise/999-seat/ far-future dev-key payloads are rejected; a legit seats=1 14-day dev trial still validates; prod-signed licenses (all tiers, 999 seats, 1-year expiry) are unaffected. 18 new tests; 110 license tests green. **Scope:** simdrive/src/simdrive/license/validator.py (dev-key branch + helper + constants) and doc corrections in public_key.py. No change to the signer, the cloud issuance routes, or the prod-key validation path. **Case B (key removal):** NOT removed. A shipped runtime path DOES sign with the dev key (license/cli.py::_issue_dev_license self-issues offline trials via SIMDRIVE_OFFLINE_DEV / `trial start --offline-dev`), so the private key must stay in the package. The tier/seats/expiry clamp is therefore the load-bearing control. Residual, by-design risk: a user can mint unlimited offline 14-day *trials* — capped to trial entitlements, never a paid tier. **Not done:** did not change the 14-day offline-trial feature itself, nor add a build-time check that the private key is absent from paid builds (not applicable while offline-dev ships). Flagged for SoD: whether offline-dev self-issuance should be gated behind a build flag so the private key can be dropped from customer wheels entirely — a larger product decision beyond this security fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- simdrive/src/simdrive/license/public_key.py | 36 +++- simdrive/src/simdrive/license/validator.py | 130 ++++++++++++- simdrive/tests/test_license_dev_key_clamp.py | 194 +++++++++++++++++++ 3 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 simdrive/tests/test_license_dev_key_clamp.py diff --git a/simdrive/src/simdrive/license/public_key.py b/simdrive/src/simdrive/license/public_key.py index 580af94d..502343f2 100644 --- a/simdrive/src/simdrive/license/public_key.py +++ b/simdrive/src/simdrive/license/public_key.py @@ -9,12 +9,23 @@ where licenses already issued under an older key keep validating until their natural expiry. -DEV KEY NOTE: +DEV KEY NOTE (SECURITY — read before touching): The DEV_VERIFY_KEY_HEX / DEV_SIGNING_KEY_HEX pair is intentionally -embedded in the package — it lets anyone self-issue a local offline -trial (simdrive trial start --email x --offline-dev) without a server. -The validator enforces that dev-key-signed licenses MUST have -subject="dev-trial"; the dev key cannot forge enterprise / pro licenses. +embedded in the package — it lets a client self-issue a local offline +trial (simdrive trial start --email x --offline-dev) WITHOUT a server +(see license/cli.py::_issue_dev_license, which signs at runtime). + +Because the PRIVATE signing key ships here, a dev-key signature proves +nothing about the payload: anyone who unpacks the wheel can forge a valid +dev-key signature over any tier/seats/expiry they like. The signature is +therefore NOT a trust boundary for dev-key licenses. The load-bearing +control is validator._enforce_dev_trial_limits(), which HARD-REJECTS any +dev-key license that is not a genuine trial (subject="dev-trial", +tier="trial", seats <= trial cap, bounded expiry). Net effect: the dev key +can only ever mint an offline TRIAL — never a paid/enterprise entitlement. +The residual, by-design risk is that a user can mint unlimited offline +14-day trials; that is the whole point of the embedded dev key and is +capped to trial entitlements. Do NOT relax the validator clamp. KEY ROTATION: TRUSTED_PUBLIC_KEYS is a list of (key_id, hex_pubkey) tuples. When a @@ -66,9 +77,11 @@ # Ed25519 dev-only keypair for offline self-issued trial licenses. -# Generated 2026-05-04. Both keys are intentionally embedded — the dev -# signing key only issues licenses with subject="dev-trial" and the -# validator rejects any non-dev-trial license signed with this key. +# Generated 2026-05-04. Both keys are intentionally embedded so the client +# can self-issue offline trials at runtime. SECURITY: the embedded PRIVATE +# key means a dev-key signature is forgeable and cannot be trusted; the +# validator hard-clamps every dev-key license to trial limits regardless of +# payload (validator._enforce_dev_trial_limits). See the DEV KEY NOTE above. DEV_VERIFY_KEY_HEX: str = ( "4ce4c377cddbcbbba91933341ac80c5d0fac152f258c9ab2a8074928e811cdd1" ) @@ -107,7 +120,10 @@ def get_dev_verify_key() -> VerifyKey: def get_dev_signing_key() -> SigningKey: """Return the embedded Ed25519 dev signing key for offline-dev trial issuance. - Intentionally public — the dev key can only sign licenses with - subject='dev-trial'; the validator rejects anything else. + Intentionally shipped so clients can self-issue offline trials. Because + the key is public, its signatures are forgeable and untrusted: the + validator (validator._enforce_dev_trial_limits) hard-clamps EVERY + dev-key license to trial limits, so a forged non-trial payload is + rejected regardless of the signature being valid. """ return signing_key_from_hex(DEV_SIGNING_KEY_HEX) diff --git a/simdrive/src/simdrive/license/validator.py b/simdrive/src/simdrive/license/validator.py index 095e530d..e7057cb6 100644 --- a/simdrive/src/simdrive/license/validator.py +++ b/simdrive/src/simdrive/license/validator.py @@ -7,8 +7,13 @@ "now" to defend against local clock backdating attacks. - Offline grace: if last_known_server_time is None (fully offline), allow 7-day window past expiry before hard-rejecting. This matches the spec. -- Dev key: licenses signed with DEV_SIGNING_KEY are accepted but MUST have - subject="dev-trial"; the dev key cannot forge enterprise/pro licenses. +- Dev key: the dev SIGNING key ships in the package (clients self-issue + offline trials at runtime), so a dev-key signature is forgeable and is + NOT a trust boundary. Licenses signed with the dev key are hard-clamped + to genuine-trial limits by _enforce_dev_trial_limits() — subject must be + "dev-trial", tier must be "trial", seats <= trial cap, and expiry within + a bounded window. This clamp (not the signature) is the load-bearing + control that stops a forged dev-key payload from escalating past a trial. - Multi-key rotation: payloads may include a ``key_id`` field naming which entry in TRUSTED_PUBLIC_KEYS signed them. Payloads without ``key_id`` fall back to the first trusted key (backwards compat @@ -48,6 +53,35 @@ # Subject value required for dev-key-signed licenses. _DEV_TRIAL_SUBJECT: str = "dev-trial" +# --- Dev-key license hard limits (LOAD-BEARING SECURITY CONTROL) --- +# The dev SIGNING key ships inside the distributed package (see +# public_key.DEV_SIGNING_KEY_HEX) because the client self-issues offline +# trials at runtime (license/cli.py::_issue_dev_license). That means the +# dev-key *signature* is NOT a trust boundary: anyone who unpacks the wheel +# can forge a valid dev-key signature over ANY payload. Therefore the +# validator — not the signature — is the only thing standing between a +# forged payload and the entitlement it claims. +# +# Invariant enforced below: a dev-key-signed license can NEVER grant more +# than a genuine offline trial, regardless of what the (fully +# attacker-controlled) payload says. We HARD-REJECT (fail closed) any +# dev-key license whose tier/seats/expiry exceed trial limits rather than +# silently clamping, so a forged enterprise/multi-seat/far-future claim +# errors loudly instead of quietly downgrading. +_DEV_TRIAL_TIER: str = "trial" +# Trial seat cap. A legitimately self-issued offline trial always carries +# seats=1 (license/cli.py) and the trial tier's max_simulators is 4 +# (license/entitlement.py). We cap at 4 — the trial tier's own ceiling — +# so a forged seats=999 is refused while the legit seats=1 trial passes. +_DEV_TRIAL_MAX_SEATS: int = 4 +# Maximum validity window for a dev-key license, measured from "now". +# The legit offline trial is 14 days (license/cli.py). We allow up to 30 +# days of headroom so a legit trial never trips this, while a forged +# far-future expiry (months/years out) is firmly rejected. Anchoring to +# the effective current time (not the payload's issued_at, which the +# attacker also controls) is what makes this bound meaningful. +_DEV_TRIAL_MAX_LIFETIME_SECONDS: int = 30 * 86400 + def _b64url_decode(s: str) -> bytes: """Decode URL-safe base64 with automatic padding.""" @@ -223,15 +257,17 @@ def validate_license( except Exception as exc: raise license_invalid(f"payload decode failed: {exc}") from exc - # ---- 4. Dev-key subject enforcement ---- + # ---- 4. Dev-key entitlement clamp (LOAD-BEARING) ---- + # The dev signing key ships in the package, so a valid dev-key signature + # proves nothing about the payload's authenticity. Enforce, fail-closed, + # that a dev-key license is a genuine trial and can never escalate beyond + # trial limits — no matter what tier/seats/expiry the payload claims. if signed_by_dev and not signed_by_prod: - subject = payload.get("subject", "") - if subject != _DEV_TRIAL_SUBJECT: - raise license_invalid( - f"dev-key-signed license must have subject={_DEV_TRIAL_SUBJECT!r}; " - f"got {subject!r}. Dev key cannot forge non-trial licenses." - ) - log.debug("license validated via dev key (offline trial)", extra={"subject": subject}) + _enforce_dev_trial_limits(payload, last_known_server_time) + log.debug( + "license validated via dev key (offline trial)", + extra={"subject": payload.get("subject", "")}, + ) # ---- 5. Expiry check with clock-skew defense ---- expires_at: int = payload.get("expires_at", 0) @@ -273,6 +309,80 @@ def validate_license( return payload +def _enforce_dev_trial_limits( + payload: dict[str, Any], + last_known_server_time: Optional[int], +) -> None: + """Fail-closed guard for dev-key-signed licenses. + + A dev-key signature is forgeable by anyone who unpacks the wheel (the + private DEV_SIGNING_KEY ships in the package), so this function — not + the signature — is the trust boundary. It guarantees the invariant: + + No dev-key license can grant more than a genuine offline trial, + regardless of the (attacker-controlled) payload. + + Enforced, each a HARD REJECT (``license_invalid``): + 1. ``subject`` must equal ``dev-trial``. + 2. ``tier`` must equal ``trial`` — a forged "enterprise"/"pro"/etc. + claim is refused loudly (never silently downgraded). + 3. ``seats`` must be <= the trial seat cap (4). + 4. ``expires_at`` must be no more than ``_DEV_TRIAL_MAX_LIFETIME_SECONDS`` + (30 days) past the effective current time — a far-future expiry + is refused. Anchored to "now", not the payload's issued_at, since + the attacker controls issued_at too. + + Raises + ------ + LicenseError(code="license_invalid") + Any of the above constraints is violated. + """ + subject = payload.get("subject", "") + if subject != _DEV_TRIAL_SUBJECT: + raise license_invalid( + f"dev-key-signed license must have subject={_DEV_TRIAL_SUBJECT!r}; " + f"got {subject!r}. Dev key cannot forge non-trial licenses." + ) + + tier = payload.get("tier") + if tier != _DEV_TRIAL_TIER: + raise license_invalid( + f"dev-key-signed license must have tier={_DEV_TRIAL_TIER!r}; " + f"got {tier!r}. Dev key cannot grant a paid tier." + ) + + # seats: coerce defensively; a non-int or over-cap value is a forgery. + seats_raw = payload.get("seats", 1) + try: + seats = int(seats_raw) + except (TypeError, ValueError): + raise license_invalid( + f"dev-key-signed license has non-integer seats {seats_raw!r}." + ) + if seats > _DEV_TRIAL_MAX_SEATS: + raise license_invalid( + f"dev-key-signed license seats={seats} exceeds the trial cap " + f"of {_DEV_TRIAL_MAX_SEATS}. Dev key cannot grant extra seats." + ) + + # expires_at: bound the validity window relative to "now" so a forged + # far-future expiry is rejected. + expires_raw = payload.get("expires_at", 0) + try: + expires_at = int(expires_raw) + except (TypeError, ValueError): + raise license_invalid( + f"dev-key-signed license has non-integer expires_at {expires_raw!r}." + ) + max_expiry = _effective_now(last_known_server_time) + _DEV_TRIAL_MAX_LIFETIME_SECONDS + if expires_at > max_expiry: + raise license_invalid( + f"dev-key-signed license expires_at={expires_at} is more than " + f"{_DEV_TRIAL_MAX_LIFETIME_SECONDS}s in the future. Dev key cannot " + "grant a long-lived license." + ) + + def _effective_now(last_known_server_time: Optional[int]) -> float: """Return the effective current time for expiry checks. diff --git a/simdrive/tests/test_license_dev_key_clamp.py b/simdrive/tests/test_license_dev_key_clamp.py new file mode 100644 index 00000000..da93033d --- /dev/null +++ b/simdrive/tests/test_license_dev_key_clamp.py @@ -0,0 +1,194 @@ +"""Security regression tests: dev-key-signed licenses can never escalate. + +Context +------- +The private dev SIGNING key ships inside the distributed package +(public_key.DEV_SIGNING_KEY_HEX) so the client can self-issue offline +trials at runtime (license/cli.py::_issue_dev_license). That means anyone +who unpacks the wheel can produce a VALID dev-key signature over ANY +payload they like — the signature is not a trust boundary. + +Historically the validator only checked ``subject == "dev-trial"`` for +dev-key licenses and read ``tier``/``seats``/``expires_at`` straight from +the (attacker-controlled) payload. Exploit: sign +``subject="dev-trial", tier="enterprise", seats=999, expires_at=`` +with the shipped dev key -> unlimited enterprise access. + +These tests forge dev-key-signed payloads directly (mirroring the on-wire +format) and assert the validator now HARD-REJECTS any escalation while +still accepting a genuine offline trial. Each test fails against the +pre-fix validator and passes after ``_enforce_dev_trial_limits``. +""" +from __future__ import annotations + +import base64 +import json +import time + +import pytest + +from simdrive.license.errors import LicenseError +from simdrive.license.public_key import get_dev_signing_key +from simdrive.license.validator import ( + _DEV_TRIAL_MAX_LIFETIME_SECONDS, + _DEV_TRIAL_MAX_SEATS, + validate_license, +) + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _dev_sign(payload: dict) -> str: + """Sign an ARBITRARY payload dict with the shipped dev key. + + This is exactly what an attacker can do after unpacking the wheel: it + reproduces license/cli.py::_issue_dev_license but with attacker-chosen + fields. Returns a ``.`` license string. + """ + sk = get_dev_signing_key() + payload_b64 = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + sig = sk.sign(payload_b64.encode("ascii")).signature + return f"{payload_b64}.{_b64url(bytes(sig))}" + + +def _legit_trial_payload(*, seats: int = 1, lifetime: int = 14 * 86400) -> dict: + now = int(time.time()) + return { + "subject": "dev-trial", + "tier": "trial", + "seats": seats, + "customer_email": "dev@example.com", + "issued_at": now, + "expires_at": now + lifetime, + } + + +# --------------------------------------------------------------------------- +# 1. Forged enterprise tier -> HARD REJECT +# --------------------------------------------------------------------------- +class TestDevKeyTierEscalationRejected: + def test_dev_key_enterprise_is_rejected(self) -> None: + """subject=dev-trial but tier=enterprise must be refused (the core exploit).""" + payload = _legit_trial_payload() + payload["tier"] = "enterprise" + payload["seats"] = 999 + payload["expires_at"] = int(time.time()) + 3650 * 86400 # ~10 years + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + # No verify_key: exercise the real prod-fallback-to-dev path. + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + @pytest.mark.parametrize("tier", ["pro", "team", "solo", "enterprise", "bogus"]) + def test_dev_key_any_non_trial_tier_rejected(self, tier: str) -> None: + payload = _legit_trial_payload() + payload["tier"] = tier + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + +# --------------------------------------------------------------------------- +# 2. Forged seat count -> rejected +# --------------------------------------------------------------------------- +class TestDevKeySeatEscalationRejected: + def test_dev_key_trial_with_999_seats_rejected(self) -> None: + payload = _legit_trial_payload(seats=999) + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + def test_dev_key_trial_at_seat_cap_ok(self) -> None: + """Seats exactly at the trial cap must still validate (boundary).""" + payload = _legit_trial_payload(seats=_DEV_TRIAL_MAX_SEATS) + key = _dev_sign(payload) + result = validate_license(key, last_known_server_time=None) + assert result["seats"] == _DEV_TRIAL_MAX_SEATS + + def test_dev_key_trial_over_seat_cap_rejected(self) -> None: + payload = _legit_trial_payload(seats=_DEV_TRIAL_MAX_SEATS + 1) + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + +# --------------------------------------------------------------------------- +# 3. Far-future expiry -> rejected +# --------------------------------------------------------------------------- +class TestDevKeyExpiryEscalationRejected: + def test_dev_key_far_future_expiry_rejected(self) -> None: + """A trial payload with a multi-year expiry must be refused.""" + payload = _legit_trial_payload(lifetime=3650 * 86400) # ~10 years + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + def test_dev_key_expiry_just_over_cap_rejected(self) -> None: + payload = _legit_trial_payload( + lifetime=_DEV_TRIAL_MAX_LIFETIME_SECONDS + 5 * 86400 + ) + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + +# --------------------------------------------------------------------------- +# 4. A LEGIT dev trial still validates (don't break offline trials) +# --------------------------------------------------------------------------- +class TestLegitDevTrialStillWorks: + def test_legit_offline_dev_trial_validates(self) -> None: + """Exactly what license/cli.py issues: subject=dev-trial, tier=trial, + seats=1, 14-day expiry — must still validate through the real path.""" + payload = _legit_trial_payload() + key = _dev_sign(payload) + result = validate_license(key, last_known_server_time=None) + assert result["tier"] == "trial" + assert result["seats"] == 1 + assert result["subject"] == "dev-trial" + + def test_dev_key_missing_subject_still_rejected(self) -> None: + """Regression guard for the original subject check — a dev-key payload + without subject=dev-trial is refused.""" + payload = _legit_trial_payload() + del payload["subject"] + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + +# --------------------------------------------------------------------------- +# 5. Prod-key-signed licenses are UNAFFECTED by the dev-key clamp +# --------------------------------------------------------------------------- +class TestProdKeyLicensesUnaffected: + @pytest.fixture + def keypair(self): + from simdrive.license.keypair import generate_keypair + return generate_keypair() + + @pytest.mark.parametrize("tier", ["trial", "solo", "pro", "team", "enterprise"]) + def test_prod_signed_all_tiers_validate(self, keypair, tier: str) -> None: + """A genuinely prod-signed license (verified via the passed verify_key) + must validate for every tier, including enterprise with many seats — + the dev-key clamp must never touch the prod path.""" + from simdrive.license.signer import sign_license + sk, vk = keypair + now = int(time.time()) + key = sign_license( + signing_key=sk, + tier=tier, + seats=999, + customer_email="paid@example.com", + issued_at=now - 86400, + expires_at=now + 365 * 86400, # 1 year — far past the dev cap + ) + result = validate_license(key, verify_key=vk, last_known_server_time=None) + assert result["tier"] == tier + assert result["seats"] == 999 From 2d812488dcb9329b66466dd4da14e9bc1ce23cef Mon Sep 17 00:00:00 2001 From: SyncTek <145518101+SyncTekLLC@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:11 -0400 Subject: [PATCH 2/2] harden(license): reject non-int/negative dev-trial seats + guard dev key in trusted set (SoD follow-ups) Implements the 3 non-blocking SoD follow-ups on the dev-key license-forge fix (cf4f56d), with regression coverage: 1/2. _enforce_dev_trial_limits now requires seats to be a genuine positive int within the trial cap: rejects float (4.9 no longer truncates to 4), str, bool (int subclass), None, and non-positive (<1) values. 3. validate_license structural guard: if the selected verify key equals the DEV verify key (e.g. mistakenly added to TRUSTED_PUBLIC_KEYS), signed_by_prod is demoted to False so the dev-trial clamp always runs. Tests (test_license_dev_key_clamp.py): seats type/range hardening; the structural guard via both trusted_keys= and a monkeypatched TRUSTED_PUBLIC_KEYS default path (forged tier=enterprise dev-key sig still REJECTED); legit dev trial still validates. **Scope:** validator.py hardening carried over from the SoD-approved fix was pre-applied; this commit adds the regression tests plus the commit itself. **Not done:** no push / no PR (per task); the unrelated journey test module that imports `anthropic` is untouched and out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- simdrive/src/simdrive/license/validator.py | 29 +++- simdrive/tests/test_license_dev_key_clamp.py | 143 +++++++++++++++++++ 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/simdrive/src/simdrive/license/validator.py b/simdrive/src/simdrive/license/validator.py index e7057cb6..2c2efbd0 100644 --- a/simdrive/src/simdrive/license/validator.py +++ b/simdrive/src/simdrive/license/validator.py @@ -238,6 +238,19 @@ def validate_license( signed_by_prod = _try_verify(selected_verify_key, payload_b64, sig_bytes) signed_by_dev = False + # Structural guard (SoD hardening): never let the DEV verify key satisfy the + # prod path. If it is ever mistakenly added to TRUSTED_PUBLIC_KEYS, + # `_select_verify_key` could return it and a dev-signed license would count + # as `signed_by_prod`, skipping the dev-trial clamp entirely. Demote it to + # the dev branch below so the clamp always runs for dev-key signatures. + if signed_by_prod: + try: + from simdrive.license.public_key import get_dev_verify_key + if bytes(selected_verify_key) == bytes(get_dev_verify_key()): + signed_by_prod = False + except Exception: + pass + if not signed_by_prod: # Try the embedded dev key as a fallback try: @@ -351,18 +364,20 @@ def _enforce_dev_trial_limits( f"got {tier!r}. Dev key cannot grant a paid tier." ) - # seats: coerce defensively; a non-int or over-cap value is a forgery. + # seats: must be a genuine positive int within the trial cap. Reject + # non-int (incl. bool, an int subclass) and float outright — `int(4.9)` + # would silently truncate a forged float to 4 — and reject non-positive + # values (SoD hardening: a float/negative/bool never reaches entitlement). seats_raw = payload.get("seats", 1) - try: - seats = int(seats_raw) - except (TypeError, ValueError): + if not isinstance(seats_raw, int) or isinstance(seats_raw, bool): raise license_invalid( f"dev-key-signed license has non-integer seats {seats_raw!r}." ) - if seats > _DEV_TRIAL_MAX_SEATS: + seats = seats_raw + if seats < 1 or seats > _DEV_TRIAL_MAX_SEATS: raise license_invalid( - f"dev-key-signed license seats={seats} exceeds the trial cap " - f"of {_DEV_TRIAL_MAX_SEATS}. Dev key cannot grant extra seats." + f"dev-key-signed license seats={seats} is outside the trial " + f"range 1..{_DEV_TRIAL_MAX_SEATS}. Dev key cannot grant extra seats." ) # expires_at: bound the validity window relative to "now" so a forged diff --git a/simdrive/tests/test_license_dev_key_clamp.py b/simdrive/tests/test_license_dev_key_clamp.py index da93033d..db82d809 100644 --- a/simdrive/tests/test_license_dev_key_clamp.py +++ b/simdrive/tests/test_license_dev_key_clamp.py @@ -192,3 +192,146 @@ def test_prod_signed_all_tiers_validate(self, keypair, tier: str) -> None: result = validate_license(key, verify_key=vk, last_known_server_time=None) assert result["tier"] == tier assert result["seats"] == 999 + + +# --------------------------------------------------------------------------- +# 6. Seats must be a GENUINE positive int (SoD hardening follow-up #1/#2) +# --------------------------------------------------------------------------- +class TestDevKeySeatsTypeAndRangeHardening: + """The old clamp did ``int(seats_raw)``, which silently accepted a forged + ``4.9`` (-> 4), ``"4"`` (-> 4), ``True`` (-> 1) and negative counts. The + hardened clamp requires a genuine ``int`` (bool excluded) in ``1..cap``.""" + + @pytest.mark.parametrize( + "bad_seats", + [ + 4.9, # float would truncate to 4 under the old int() coercion + 2.0, # even a whole-valued float is not a genuine int + "4", # string would coerce to 4 under int() + "1", # string within range still refused (must be real int) + True, # bool is an int subclass -> int(True) == 1; must reject + False, # int(False) == 0; reject on type before range + None, # non-int + ], + ) + def test_dev_key_non_int_seats_rejected(self, bad_seats) -> None: + payload = _legit_trial_payload() + payload["seats"] = bad_seats + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + @pytest.mark.parametrize("bad_seats", [-1, 0]) + def test_dev_key_non_positive_seats_rejected(self, bad_seats: int) -> None: + """seats below the trial range (<1) is a forgery -> reject.""" + payload = _legit_trial_payload(seats=bad_seats) + key = _dev_sign(payload) + with pytest.raises(LicenseError) as exc: + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + def test_dev_key_int_seats_at_boundary_accepted(self) -> None: + """A genuine int at the seat cap (4) is still a valid trial.""" + payload = _legit_trial_payload(seats=_DEV_TRIAL_MAX_SEATS) # == 4 + key = _dev_sign(payload) + result = validate_license(key, last_known_server_time=None) + assert result["seats"] == _DEV_TRIAL_MAX_SEATS + assert isinstance(result["seats"], int) + + def test_dev_key_int_seats_one_accepted(self) -> None: + """The canonical single-seat trial is a genuine int and still valid.""" + payload = _legit_trial_payload(seats=1) + key = _dev_sign(payload) + result = validate_license(key, last_known_server_time=None) + assert result["seats"] == 1 + + +# --------------------------------------------------------------------------- +# 7. Structural guard (SoD hardening follow-up #3): the DEV verify key must +# NEVER satisfy the prod path, even if it is mistakenly placed in the +# trusted-key set. Otherwise `_select_verify_key` would return it, the +# dev signature would count as `signed_by_prod`, and the clamp would be +# skipped -> full enterprise forgery. This is the important one. +# --------------------------------------------------------------------------- +class TestStructuralGuardDevKeyInTrustedSet: + def _forged_enterprise(self, *, key_id: str | None = None) -> str: + """Forge subject=dev-trial but tier=enterprise/seats=999/far-future, + signed with the shipped DEV key. If the clamp runs it is rejected + (tier != trial); if the guard fails to demote signed_by_prod it would + be accepted as a genuine enterprise license.""" + payload = _legit_trial_payload() + payload["tier"] = "enterprise" + payload["seats"] = 999 + payload["expires_at"] = int(time.time()) + 3650 * 86400 # ~10 years + if key_id is not None: + payload["key_id"] = key_id + return _dev_sign(payload) + + def test_dev_key_present_in_trusted_keys_still_clamped(self) -> None: + """Pass the DEV verify key in `trusted_keys` (mistakenly "trusting" + it) and a key_id in the payload that selects it. The dev signature + verifies against it -> would be `signed_by_prod=True`; the structural + guard must demote it so the clamp runs and enterprise is REJECTED.""" + from simdrive.license.public_key import get_dev_verify_key + + dev_key_id = "dev-oops-trusted" + key = self._forged_enterprise(key_id=dev_key_id) + trusted = [(dev_key_id, get_dev_verify_key())] + + # Sanity: the signature genuinely verifies against the dev key placed + # in the trusted set, so acceptance would hinge solely on the guard. + from simdrive.license.validator import ( + _b64url_decode, + _select_verify_key, + _try_verify, + ) + payload_b64, sig_b64 = key.split(".") + sel = _select_verify_key(payload_b64, verify_key=None, trusted_keys=trusted) + assert bytes(sel) == bytes(get_dev_verify_key()) + assert _try_verify(sel, payload_b64, _b64url_decode(sig_b64)) + + with pytest.raises(LicenseError) as exc: + validate_license(key, trusted_keys=trusted, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + def test_dev_key_prepended_to_TRUSTED_PUBLIC_KEYS_still_clamped( + self, monkeypatch + ) -> None: + """Same attack via the real default path: monkeypatch the module-level + TRUSTED_PUBLIC_KEYS so the dev key is the first (legacy-selected) entry, + then validate a forged enterprise license with no key_id. The guard + must still force the clamp.""" + from simdrive.license import public_key + + monkeypatch.setattr( + public_key, + "TRUSTED_PUBLIC_KEYS", + [ + ("dev-oops", public_key.DEV_VERIFY_KEY_HEX), + (public_key.PROD_KEY_ID, public_key.SIMDRIVE_PUBLIC_KEY_HEX), + ], + ) + + key = self._forged_enterprise() # no key_id -> pins to first (dev) key + with pytest.raises(LicenseError) as exc: + # trusted_keys omitted -> falls back to the (patched) module list. + validate_license(key, last_known_server_time=None) + assert exc.value.code == "license_invalid" + + def test_dev_key_in_trusted_set_legit_trial_still_validates(self) -> None: + """Even with the dev key mistakenly trusted, a genuine dev trial still + validates (demotion routes it through the dev branch, clamp passes).""" + from simdrive.license.public_key import get_dev_verify_key + + dev_key_id = "dev-oops-trusted" + payload = _legit_trial_payload() + payload["key_id"] = dev_key_id + key = _dev_sign(payload) + trusted = [(dev_key_id, get_dev_verify_key())] + result = validate_license( + key, trusted_keys=trusted, last_known_server_time=None + ) + assert result["tier"] == "trial" + assert result["seats"] == 1 + assert result["subject"] == "dev-trial"