Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions simdrive/src/simdrive/license/public_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
145 changes: 135 additions & 10 deletions simdrive/src/simdrive/license/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -204,6 +238,19 @@
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:
Expand All @@ -223,15 +270,17 @@
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", "")},

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (secret)
as clear text.
This expression logs
sensitive data (secret)
as clear text.
)

# ---- 5. Expiry check with clock-skew defense ----
expires_at: int = payload.get("expires_at", 0)
Expand Down Expand Up @@ -273,6 +322,82 @@
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: 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)
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}."
)
seats = seats_raw
if seats < 1 or seats > _DEV_TRIAL_MAX_SEATS:
raise license_invalid(
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
# 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.

Expand Down
Loading
Loading