fix(license): close dev-key privilege-escalation — clamp dev-signed licenses to trial - #177
Merged
Merged
Conversation
…fix) 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=<far future>` 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
| _enforce_dev_trial_limits(payload, last_known_server_time) | ||
| log.debug( | ||
| "license validated via dev key (offline trial)", | ||
| extra={"subject": payload.get("subject", "")}, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security fix (release-gating)
Confirmed vulnerability: the embedded dev signing key ships in the wheel and production trusts it, while the validator enforced only
subject=="dev-trial"— nottier/seats/expires_at. A license forged with the shipped dev key as{subject:"dev-trial", tier:"enterprise", seats:999}validated → unlimited enterprise, offline, permanent. Pre-fix probe:EXPLOIT SUCCEEDED (tier=enterprise seats=999).Fix
_enforce_dev_trial_limits()invalidator.py— a dev-key-signed license can never exceed a trial (fail-closed hard-rejects):tiermust be"trial"(paid tiers refused loudly)seatsmust be a genuine int in1..4(the trial tier's ownmax_simulators) — rejects float/bool/string/negative (hardening)expires_at≤ now + 30d (anchored to now, not attacker-controlledissued_at)subjectmust be"dev-trial"(preserved)Plus a structural guard: if the dev verify key is ever added to
TRUSTED_PUBLIC_KEYS, it's demoted from the prod path so the clamp still runs.Case B note: the private key can't be dropped —
cli.py::_issue_dev_licenseself-issues offline trials at runtime (--offline-dev). So the clamp is the load-bearing control; residual by-design risk is unlimited offline trials (never a paid tier). Follow-up (out of scope): gate self-issuance behind a build flag to drop the key from customer wheels.Verification
test_license_dev_key_clamp.py32 passed (incl. the structural guard proven two ways, type/range hardening, and legit-trial still validates). Full license suite 115 passed. ruff clean.REJECTED (license_invalid).Do not release simdrive until this is merged.