Skip to content

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

LicenseControllerGET /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:

PFSH-<base64url_payload>.<base64url_signature>

The key consists of three parts:

  1. Mandatory prefix: PFSH-
  2. Base64url payload: an encoded JSON object describing the license (licenseId, holder, plan, issuedAt, expiresAt)
  3. 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):

PFSH-eyJsaWNlbnNlSWQiOiJwc2gtMDAwMSIsImhvbGRlciI6...cGxhbiI6InBlcmZvcm1hbmNlIn0.qwertyABC...xyz987

The validity of a key is verified by the LicenseService.parseLicense() service which:

  1. Checks the PFSH- prefix
  2. Splits the payload and signature on the last period .
  3. Loads the RSA public key embedded in the backend JAR
  4. Verifies the RSA-PSS signature (SHA-256 + MGF1 + 32-byte salt)
  5. 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

{ "licenseKey": "PFSH-eyJsaWNlbnNlSWQiOi...cGxhbiI6InBlcmZvcm1hbmNlIn0.qwertyABC...xyz987" }
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:

  1. Environment variable PERFSHOP_LICENSE_KEY (or Spring property perfshop.license.key) — used for .env deployments
  2. perfshop_license table in the DB (row id='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.

POST /api/license/revoke HTTP/1.1

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

  1. 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
  2. Cache emptied: cachedLicense.set(null)isLicenseValid() returns false immediately
  3. Chaos already running is not interrupted (a chaos already active keeps running until its manual reset)
  4. Students receive 402 Payment Required on 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:

{
  "error": "Your plan does not include this feature (jmeter-ui)"
}

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 /status returns 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.