API — License management¶
This page documents LicenseController, mounted under /api/license. It handles activation, revocation and consultation of the PerfShop commercial license, which unlocks advanced chaos levels for standalone use.
Controller covered
LicenseController → GET /status, POST /activate, POST /revoke
The PerfShop license model¶
PerfShop runs on a freemium model: the full e-commerce journey (products, cart, orders, accounts, administration) is accessible in free mode, but advanced chaos levels in student self-service (beyond level 1 for performance, beyond level 0 for other families) require a valid license.
The boundary between the two modes is the 402 Payment Required HTTP code returned by student endpoints when a level exceeds the free limit. See chaos-student.md.
Freemium vs pedagogical
Even without a license, the instructor can activate any chaos level via the /api/admin/chaos/* endpoints — they are identified as admin and are not subject to the freemium wall. The license protects only student self-service and the driving of pedagogical journeys (/pedagogique/activate, /pedagogique/join, /pedagogique/level).
This rule reflects the real PerfShop use case: the license is required for instructors who want their students to train autonomously, not for one-off demos where the instructor drives things themselves.
The three commercial plans¶
PerfShop distinguishes three plans that unlock different feature sets:
| Plan | Internal identifier | Unlocked interfaces |
|---|---|---|
| Functional Testing 🧪 | functional |
chaos-admin, admin, monitoring, scripts-ui |
| Performance Testing 🎯 | performance |
Functional + jmeter-ui |
| Enterprise 🏢 | enterprise |
All features including future ones |
When no valid license is active, the internal plan is none and all protected interfaces return 402 Payment Required.
License key format¶
PerfShop license keys follow a standardized format:
The key consists of three parts:
- Mandatory prefix:
PFSH- - Base64url payload: an encoded JSON object describing the license (
licenseId,holder,plan,issuedAt,expiresAt) - Base64url signature: separated from the payload by a period
., this is an RSA-PSS 2048-bit signature with SHA-256 computed over the ASCII bytes of the payload
Example shape (not a real key):
The validity of a key is verified by the LicenseService.parseLicense() service which:
- Checks the
PFSH-prefix - Splits the payload and signature on the last period
. - Loads the RSA public key embedded in the backend JAR
- Verifies the RSA-PSS signature (
SHA-256+MGF1+ 32-byte salt) - Decodes the JSON payload and extracts the fields
No network call is required — verification is entirely offline.
Public key integrity protection¶
The backend embeds the official RSA public key as a source constant and verifies its SHA-256 at startup (@PostConstruct). If the hash does not match the expected hash (EXPECTED_PUBLIC_KEY_HASH), the JVM refuses to start with an IllegalStateException. This check prevents a malicious fork of PerfShop from substituting its own public key to sign fraudulent licenses — any modification forces AGPL publication of the fork.
Overview¶
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/license/status |
None | Status of the current license |
POST |
/api/license/activate |
None 🔑 | Activates a license key |
POST |
/api/license/revoke |
None 🔑 | Revokes the active license |
Why /activate and /revoke are public
Both mutation endpoints are intentionally public to avoid an operational deadlock:
/activate: without a valid license, all protected authentication interfaces are blocked (HTTP 402). If activation required authentication, it would be impossible to authenticate to activate your first license. The endpoint is therefore public, with security provided by RSA-PSS verification: a forged key is rejected./revoke: with a revoked license, admin auth is blocked → deadlock if this endpoint required authentication. The endpoint is public, and its impact is limited: without a valid license, the interfaces are simply blocked in 402. A new activation requires a valid RSA key.
GET /api/license/status¶
Returns the current license state. Endpoint frequently polled by the chaos-admin, scripts-ui, jmeter-ui interfaces to condition their display before any authentication attempt.
Auth: none
Response — 200 OK (active license)¶
{
"valid": true,
"plan": "performance",
"planLabel": "🎯 Performance Testing",
"holder": "Acme Training Corp",
"issuedAt": "2025-01-15",
"expiresAt": "2026-01-15",
"unlimited": false,
"daysRemaining": 282,
"features": [
"chaos-admin",
"admin",
"monitoring",
"scripts-ui",
"jmeter-ui"
]
}
| Field | Description |
|---|---|
valid |
true if a license is active and not expired |
plan |
Internal plan identifier: functional, performance or enterprise |
planLabel |
Readable plan label with emoji |
holder |
Holder name recorded in the signed payload |
issuedAt |
Issue date in YYYY-MM-DD format |
expiresAt |
Expiration date in YYYY-MM-DD format, or null if the license is unlimited |
unlimited |
true if expiresAt == null |
daysRemaining |
Number of days remaining until expiration, or null if unlimited. Floored at 0 (no negative value) |
features |
List of interfaces unlocked by this plan |
Response — 200 OK (no active license)¶
{
"valid": false,
"plan": "none",
"planLabel": "No license",
"message": "No valid license. Activate your license via the panel below."
}
The planLabel and message fields are localized via the license.status.none_label and license.status.none_message keys.
This endpoint never returns an error — even on a corrupted key, expiration or internal issue, it responds 200 OK with valid: false. It is designed to be polled continuously without risk.
POST /api/license/activate¶
Activates a license key. The key is verified cryptographically via RSA-PSS signature, then persisted in the perfshop_license table and loaded into the memory cache.
Auth: none (see justification above)
Request¶
| Field | Type | Required | Description |
|---|---|---|---|
licenseKey |
string | yes | Full license key in the format PFSH-<payload>.<signature> |
Response — 200 OK¶
{
"success": true,
"message": "License activated successfully",
"status": {
"valid": true,
"plan": "performance",
"planLabel": "🎯 Performance Testing",
"holder": "Acme Training Corp",
"issuedAt": "2025-01-15",
"expiresAt": "2026-01-15",
"unlimited": false,
"daysRemaining": 365,
"features": ["chaos-admin", "admin", "monitoring", "scripts-ui", "jmeter-ui"]
}
}
The success message is localized via license.activate.success. The status block is identical to what GET /status returns after activation.
Validation process¶
flowchart TB
A[POST /license/activate] --> B{licenseKey field present?}
B -->|no| E1[400 — license.error.key_empty]
B -->|yes| C{Starts with PFSH-?}
C -->|no| E2[400 — license.error.key_format]
C -->|yes| D[LicenseService.activateLicense]
D --> F[Parse payload + RSA-PSS signature check]
F -->|invalid signature| E3[422 — license.error.invalid_signature]
F -->|expired date| E4[422 — license.error.expired]
F -->|OK| G[UPSERT perfshop_license]
G --> H[cachedLicense.set]
H --> I[200 OK]
Error codes¶
| Code | Body | Cause |
|---|---|---|
| 400 | {"error": "The 'licenseKey' field is required"} |
Missing or empty field (key license.error.key_empty) |
| 400 | {"error": "Invalid format — the key must start with PFSH-"} |
Missing prefix (key license.error.key_format) |
| 422 | {"error": "Invalid key — incorrect RSA signature or unrecognized format"} |
Invalid signature, corrupted payload or unknown plan (key license.error.invalid_signature) |
| 422 | {"error": "License expired on 2024-12-31"} |
Expiration date in the past (key license.error.expired) |
Code 422 for cryptographic errors
Format errors (400) and cryptographic verification errors (422) are distinct. A 400 indicates that the request itself is malformed (missing field or bad prefix), a 422 indicates that the request is well-formed but the content of the key is not valid (incorrect signature or expiration passed). This distinction lets a client differentiate a user input error from a cryptographic rejection.
Database persistence¶
Activation performs an upsert on the perfshop_license table with the fixed key id='current':
-- Update attempt (if the row already exists)
UPDATE perfshop_license
SET license_key=?, plan=?, holder=?,
issued_at=?, expires_at=?,
activated_at=NOW(), revoked=0
WHERE id='current';
-- If UPDATE touched no row, INSERT
INSERT INTO perfshop_license (id, license_key, plan, holder, issued_at, expires_at)
VALUES ('current', ?, ?, ?, ?, ?);
One active license per instance — successive activations overwrite the previous one.
Load priority at startup¶
At backend startup (@PostConstruct), LicenseService.loadLicense() looks for the license in two sources in order of priority:
- Environment variable
PERFSHOP_LICENSE_KEY(or Spring propertyperfshop.license.key) — used for.envdeployments perfshop_licensetable in the DB (rowid='current'not revoked) — used after activation via the API
If no source provides a valid license, the cache remains empty and all protected interfaces return 402.
POST /api/license/revoke¶
Revokes the active license: marks revoked=1 in the perfshop_license table and empties the memory cache.
Auth: none (see justification above)
Request¶
No body required.
Response — 200 OK¶
{
"success": true,
"message": "License revoked — protected interfaces locked",
"status": {
"valid": false,
"plan": "none",
"planLabel": "No license",
"message": "No valid license. Activate your license via the panel below."
}
}
The status block reflects the state after revocation — identical to GET /status with no license.
Error codes¶
| Code | Body | Cause |
|---|---|---|
| 409 | {"error": "No active license to revoke"} |
No valid license to revoke (key license.error.no_active) |
Revocation effects¶
- SQL attempt:
UPDATE perfshop_license SET revoked = 1 WHERE id = 'current'. If the update fails (DB error), the message is logged at WARN but the operation continues - Cache emptied:
cachedLicense.set(null)—isLicenseValid()returnsfalseimmediately - Chaos already running is not interrupted (a chaos already active keeps running until its manual reset)
- Students receive
402 Payment Requiredon their next attempt to activate a level > freemium
Feature locking by license¶
The service exposes a hasFeature(String feature) method called by controllers to check whether the current plan gives access to a feature. The rules are:
| Plan | functional |
performance |
enterprise |
|---|---|---|---|
chaos-admin |
✅ | ✅ | ✅ |
admin |
✅ | ✅ | ✅ |
monitoring |
✅ | ✅ | ✅ |
scripts-ui |
✅ | ✅ | ✅ |
jmeter-ui |
❌ | ✅ | ✅ |
| others | ❌ | ✅ | ✅ |
When an endpoint denies access due to an insufficient plan, it returns 402 with the generic message license.error.feature_denied:
The {0} placeholder is replaced by the identifier of the denied feature.
curl example — activation and revocation¶
# 1. Initial state — no license
curl http://localhost:9080/api/license/status
# {
# "valid": false,
# "plan": "none",
# "planLabel": "No license",
# "message": "No valid license. ..."
# }
# 2. Attempt a student chaos level → 402
curl -H "Content-Type: application/json" \
-d '{"level":3}' \
http://localhost:9080/api/chaos/student/performance
# { "error": "LICENSE_REQUIRED", "requested": 3, "maxFree": 1, ... }
# 3. Activate a Pro license
curl -H "Content-Type: application/json" \
-d '{"licenseKey":"PFSH-eyJ...signaturexyz"}' \
http://localhost:9080/api/license/activate
# 4. Check status after activation
curl http://localhost:9080/api/license/status
# { "valid": true, "plan": "performance", "daysRemaining": 365, ... }
# 5. Retry chaos level 3 → 200
curl -H "Content-Type: application/json" \
-d '{"level":3}' \
http://localhost:9080/api/chaos/student/performance
# 6. End of course — public revocation
curl -X POST http://localhost:9080/api/license/revoke
Implementation security¶
PerfShop protects the license mechanism against several classes of attacks:
Offline RSA-PSS signature verification¶
No external server call is needed to validate a key — the public key is embedded in the backend JAR. This guarantees:
- Offline operation (training room without Internet)
- No client IP leaking to an external license server
- No dependency on a third-party service
Anti-substitution of the public key¶
The SHA-256 hash of the embedded public key is verified at startup. A PerfShop fork that substituted another public key to sign its own licenses would see the backend refuse to start. Combined with the AGPL license, this forces publication of any modification and makes fraudulent licenses detectable.
No key disclosure¶
The activated key is stored in the DB and in the cache, but is never returned by the API:
GET /statusreturns the plan, holder, dates — never the key- Logs do not include the cleartext key
- An activation error does not expose the entered value
Replay protection¶
A key already used can be reactivated as many times as needed — there is no anti-replay protection server-side. This is intentional: training instances are rebuilt frequently, and forcing a systematic revocation would needlessly complicate pedagogical workflows. Commercial control is handled upstream (renewal cycle, per-seat billing).
Points of attention¶
No automatic chaos shutdown on expiration
When a license expires, the backend automatically treats it as invalid on the next validation — but chaos already running at the time of expiration keeps running. This is a compromise to avoid a running course being abruptly interrupted. The instructor will still see the "license expired" badge in monitoring and will need to renew as soon as possible.
One license per instance
The perfshop_license table uses a fixed primary key id='current' — there can be only one active license at a time. Activating a new key overwrites the previous one (even if it was a higher plan). To temporarily switch between plans, original keys must be kept outside the system.
Related links¶
chaos-student.md— detailed freemium wall- License system — overview — RSA-PSS cryptographic details
admin.md— admin authentication blocked in the absence of a license