Modeva
2.1.0Integrated tool for model development and validation.
License Sources
| Source | License | Class |
|---|---|---|
Licensie (detected) | Pending | - |
PyPI (reported) | proprietary | Unknown |
License detection is still in progress for this version.
Loading dependencies…
License File
# MoDeVa Licensing Design
*Prepared for team review — 2026-06-18. Branch: `feat/nuitka-enterprise-build`.*
Design for the license system shipped inside the compiled (Nuitka) MoDeVa
wheel. Models the approach used by the sibling Knowlytix GMS product.
---
## Decisions (settled)
| Decision | Choice | Rationale |
|---|---|---|
| License transport | **Pure offline signed JWT (RS256)** | Tamper-proof entitlement, verified offline against an embedded public key. No runtime server. |
| Machine binding | **None (honor-system)** | Removes UUID-ping fragility and VM/CI/airgap support burden. Anti-sharing rests on expiry + compliance. |
| Tiers | **demo (keyless), developer (180d), enterprise (365d)** | Classroom dropped. Demo = keyless eval; developer = 6-month trial; enterprise = commercial. |
| Demo (no key) | **Keyless eval, built-in datasets only** | `pip install` works out of the box at demo caps; loading *custom* data needs a key. Not time-boxed (keyless). |
| Capability gating | **Scale-only numeric caps** (+ demo's built-in-only gate) | `max_dataset_rows`, `max_registered_models`, `max_workers`; demo additionally blocks custom-data loading. |
| Feature availability | **All tools open to all paid tiers** | Demo is the limited exception (built-in data only). |
| Commercial use | **Enterprise only; EULA + enforced** | Demo/developer are non-commercial (EULA). Enterprise is commercial AND issuable only by the internal key (see §1b). |
| Developer issuance | **Internal now; self-serve later** | `provision.py --template developer` today; the self-serve Worker (`provisioning/issuer-worker/`, NOT deployed) automates it later. |
| Online server (Supabase) | **Removed from the client** | Pure-offline → no runtime server. Eliminates the exposed-credential problem. |
### Consequences for the current security debt
- **(A) default-on `MODEVA_DEV_MODE` bypass → fixed** (auth.py rewritten; no key now → restricted demo, not full access).
- **(B) forgeable XOR offline licenses → fixed** by the signed JWT.
- **(C) exposed Supabase creds → fixed** by removing the client-side Supabase code.
---
## 1. License key format
A signed JWT, prefixed for human recognition: `modeva_live_<jwt>`.
```json
{
"customer_id": "user@org.com",
"tier": "developer | enterprise", // demo is keyless (no token)
"expires": "2026-12-16", // ISO date = issue date + duration_days
"key_version": 1,
"caps": {
"max_dataset_rows": 50000,
"max_registered_models": 10,
"max_workers": 1
}
}
```
- **Signature:** RS256. Private key held only by the provisioner; the wheel
embeds the **public** key(s) (safe to ship — cannot forge).
- **`kid`** (JWT header): names the public key the client should verify against.
- **`expires`** is a custom ISO-date claim (not the numeric JWT `exp`); "expires"
means the last valid day inclusive.
- **No `machine_id`, no `commercial_use`** claim (honor-system; commercial scope
lives in the EULA).
---
## 1a. Key management & rotation (multi-key)
Only **public** keys ever ship. The verifier trusts a *set* of public keys:
`modeva/modeva-public.pem` (primary, `kid="modeva-public"`) plus every
`modeva/_license_keys/<id>.pem` (rotation keys, `kid="<id>"`). A license verifies
if its signature matches **any one** trusted key (JWKS-style), so multiple
keypairs can coexist.
**Where each half lives:**
- Private keys → KMS / secret store only (and the GitHub Actions secret
`MODEVA_PRIVATE_KEY` for CI smoke tests). Never committed (`.gitignore`).
- Public keys → committed and bundled in the wheel.
**Rotating the signing key without downtime:**
1. Generate a new keypair; keep the new private key in the KMS.
2. Commit the new **public** key to `modeva/_license_keys/<new-id>.pem` and cut a
release. Wheels now trust both old and new keys.
3. Issue new/renewed licenses with the new private key:
`provision.py --kid <new-id> --private-key <new-private.pem> ...`. Old licenses
keep verifying against the old key **until they expire**.
4. After all old-key licenses have expired, remove the old public key in a later
release. The system then runs on the new key alone.
> Caveat: pure-offline licensing has no real revocation. Rotating because of a
> *leaked* private key only protects customers who take the new wheel — it cannot
> invalidate already-issued (or attacker-minted) licenses on copies that don't
> upgrade. Multi-key is what lets a rotation *start* without breaking everyone.
---
## 1b. Commercial = internal-only (tier→key binding)
Two signing keys, both trusted by the wheel:
- **internal** (`kid="modeva-public"`) — the internal team's `provision.py`; may
issue **any** tier (developer or enterprise).
- **self-serve** (`kid="selfserve"`) — the (future) public Worker endpoint; in
practice issues developer.
`verify_license` records *which trusted key actually verified the signature* and
enforces `_TIER_KID_RESTRICTIONS = {"enterprise": {"modeva-public"}}`: a
`tier=enterprise` token is rejected unless it was verified by the **internal**
key. So a compromised public endpoint (self-serve key) can mint developer keys
but **never a valid commercial license**. Spoofing the `kid` header doesn't help —
the check is on the verifying key, not the header. `developer` is unrestricted so
it can be issued internally now *and* by the endpoint later.
---
## 2. Tier templates *(strawman caps — tune before launch)*
| | demo (keyless) | developer (180d) | enterprise (365d) |
|---|---|---|---|
| `max_dataset_rows` | 50,000 | 50,000 | unlimited |
| `max_registered_models` | 3 | 10 | unlimited |
| `max_workers` (n_jobs) | 1 | 1 | unlimited |
| custom data (own CSV/DataFrame) | ❌ built-in only | ✅ | ✅ |
| commercial use | ❌ | ❌ | ✅ |
| issued by | — (keyless) | internal now / self-serve later | internal only |
`"unlimited"` is a sentinel parsed to a typed `Unlimited` singleton so call sites
branch on `isinstance(cap, Unlimited)` rather than magic numbers.
---
## 3. Runtime verification flow
Called once at import (replacing today's `auth.run()` checkpoints):
1. **Read key** — precedence: `MODEVA_LICENSE_KEY` env var → `~/.modeva/license.key`.
2. **Verify RS256** signature against the embedded public key. Any failure →
generic `LicenseError("signature verification failed.")` (**fail-closed**).
3. **Check expiry** — `expires < today` → expired error with renewal URL.
4. **Validate `caps`** against the schema; missing/malformed caps on a known
`key_version` = tampering → fail-closed.
5. **Publish caps** via `set_caps()` for enforcement call sites.
6. **Startup banner** to stderr: `modeva vX licensed to customer=… tier=… expires=…`.
`get_caps()` raises if called before successful verification — a tamper that
strips the verify call fails closed (no caps → error), never open.
---
## 4. Enforcement (numeric caps at existing chokepoints)
`auth.run()` is already invoked in `DataSet`, `ModelZoo`, `TestSuite`, and every
model constructor — caps slot into those same points:
| Cap | Where | Behaviour |
|---|---|---|
| `max_dataset_rows` | `DataSet.load` / `_set_raw_data` | **Reject** with actionable error ("developer tier supports up to 50k rows; sample your data or upgrade") — clearer than silently truncating data |
| `max_registered_models` | `ModelZoo.register` | **Reject** beyond cap |
| `max_workers` | `n_jobs` plumbing | **Clamp** downward (clamp, don't crash) |
---
## 5. Provisioning
A `provisioning/provision.py` CLI (kept **out** of the shipped wheel):
```
provision.py --template {developer|enterprise} \
--customer user@org.com \
[--expires YYYY-MM-DD] # else issue-date + duration_days \
[--kid <signing-key-id>] # default "modeva-public" (internal) \
[--overrides customer.json] # enterprise only, tighten-only
```
- Loads `provisioning/templates/<tier>.json` (duration + caps).
- Computes `expires`, signs with the private key, prints `modeva_live_<jwt>`.
- **Enterprise overrides** may only *tighten* caps (int ≤ template; bool
true→false) and duration; developer is a fixed shape.
- Records the signing `kid`, resolved caps, and customer in the **tracked**
`provisioning/issued.jsonl` log, and emits a ready-to-paste email snippet.
- **Private key** lives in a KMS / secret store — never in the repo or a wheel.
- The (future) self-serve Worker at `provisioning/issuer-worker/` automates
developer issuance online; **not deployed** — see its README. Key-retirement
timing: `provisioning/key_status.py`.
---
## 6. Packaging notes
- Embed `modeva/modeva-public.pem`; load via **`importlib.resources`** (not
`__file__` math) — this sidesteps the Nuitka `..`/normpath resource gotcha
entirely.
- Add the PEM to `package_data` in **both** `setup.py` and `setup_nuitka.py`.
- `provisioning/` and `templates/` are **excluded** from the wheel (private-key
tooling must never ship).
---
## 7. Security prerequisites & threat model
**Done:** the old default-on `MODEVA_DEV_MODE` full-access bypass is gone. No key
now drops to a **restricted demo** (built-in datasets only), not full access.
**Ceiling (honest):** all checks run client-side in the wheel. A determined
attacker can patch the compiled binary. The goal is to deter casual bypass and
make the granted scope **auditable** (the signature is tamper-proof evidence) —
not to defeat reverse engineering. Compilation (and optionally Nuitka Commercial
constant-encryption) raises that bar; see `COMPILATION_COMPARISON.md`.
---
## 8. File layout
```
modeva/_license.py # verify(), key sources, multi-key load, tier->kid binding
modeva/_caps.py # schema, Unlimited, apply_ceiling, get/set_caps, tier defaults (+ DEMO_CAPS)
modeva/auth.py # verify once + publish caps; require_licensed_feature() (demo gate)
modeva/modeva-public.pem # embedded INTERNAL public key (shipped)
modeva/_license_keys/*.pem # additional trusted public keys (rotation + selfserve), shipped
provisioning/provision.py # internal issuance CLI (NOT shipped)
provisioning/key_status.py # when is a signing key safe to retire (NOT shipped)
provisioning/templates/{developer,enterprise}.json # (NOT shipped)
provisioning/issued.jsonl # TRACKED internal issuance log (NOT shipped in wheel)
provisioning/issuer-worker/ # self-serve trial Worker (future infra, NOT deployed)
```
---
## 9. Open items
- Confirm the strawman cap numbers (§2), incl. demo caps.
- `EULA.md`: legal entity name (§3); counsel review. Non-commercial clause done.
- Production keys: generate internal + self-serve keypairs in a KMS; commit their
public halves; private keys to KMS + (for the Worker) a Worker secret.
- If/when the self-serve Worker is deployed: generate `selfserve.pem`, set the
Worker secrets + Turnstile, and consider D1 for a queryable trial user list.
- Migration: existing PyPI `Modeva` 1.0.x users upgrading to 1.1.0 now hit
licensing (demo if no key) — decide comms / version story (see RELEASE.md).