License system¶
PerfShop is released under a dual license: AGPL-3.0-or-later for open source use, and a commercial license for uses that cannot satisfy the AGPL obligations (see License). Access to the platform's sensitive interfaces is conditional on a commercial license key validated cryptographically. This page describes the technical operation of the system.
Sources
backend/src/main/java/com/perfshop/service/LicenseService.java, controller/LicenseController.java, chaos/LicenseInterceptor.java, backend/src/main/resources/db/migration-fr/V1__schema.sql (table perfshop_license)
Key format¶
A PerfShop license key follows the format:
The PFSH- prefix is an explicit marker that allows immediate form validation (even before calling the cryptographic algorithm). The body consists of two segments separated by a dot:
payload— a JSON object encoded in base64url containing the license informationsignature— the RSA-PSS signature of the payload, also encoded in base64url
JSON payload¶
{
"licenseId": "uuid-v4",
"holder": "Holder name or organization",
"plan": "functional | performance | enterprise",
"issuedAt": "2026-01-15",
"expiresAt": "2027-01-15"
}
The expiresAt field can be null for an unlimited license. Dates are in ISO YYYY-MM-DD format.
Cryptographic algorithm¶
The signature uses RSA-PSS with SHA-256, parameterized as follows:
- Public key: RSA 2048 bits (SPKI / X.509 DER format, embedded in
LicenseService.javaas PEM) - Algorithm:
RSASSA-PSS - Hash:
SHA-256 - MGF:
MGF1withSHA-256 - Salt length:
32bytes - Trailer field:
1
Signature sig = Signature.getInstance("RSASSA-PSS");
PSSParameterSpec pssSpec = new PSSParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1);
sig.setParameter(pssSpec);
sig.initVerify(publicKey);
sig.update(payloadB64.getBytes(StandardCharsets.UTF_8));
boolean valid = sig.verify(fromBase64url(sigB64));
The corresponding private key stays on perfshop.io and never leaves the generation server. It alone can forge valid licenses. Attempting to forge a key without access to this private key is computationally infeasible (standard RSA 2048 security).
Public key integrity protection¶
LicenseService.java hard-codes the public key and also stores the expected SHA-256 of this key in the EXPECTED_PUBLIC_KEY_HASH constant. At startup (@PostConstruct init()), the service computes the hash of the embedded key and compares it to the expected value:
private void verifyPublicKeyIntegrity() {
byte[] hashBytes = MessageDigest.getInstance("SHA-256")
.digest(PUBLIC_KEY_PEM.getBytes(StandardCharsets.UTF_8));
String actualHash = bytesToHex(hashBytes);
if (!actualHash.equals(EXPECTED_PUBLIC_KEY_HASH)) {
throw new IllegalStateException("PerfShop license public key integrity check failed");
}
}
If the hash does not match, the JVM refuses to start. This is a safety net against a public key substitution: an attacker who would recompile PerfShop to inject their own public key (in order to forge their own licenses) would also have to modify EXPECTED_PUBLIC_KEY_HASH. This modification is detectable by diff and materializes a derivation of the source code, which triggers the AGPL publication obligations.
This is not a cryptographic protection — a determined attacker can always bypass this check. It is an explicit safeguard that makes any alteration visible and forces transparency.
Loading priority¶
At startup, LicenseService.loadLicense() attempts to load a license in this order:
flowchart TD
Start([Spring Boot startup]) --> CheckEnv{PERFSHOP_LICENSE_KEY<br/>in .env?}
CheckEnv -- yes --> ParseEnv[Parse + verify signature]
ParseEnv -- valid --> CacheEnv[Memory cache<br/>active license]
ParseEnv -- invalid --> CheckDB
CheckEnv -- no --> CheckDB{Row in<br/>perfshop_license<br/>id='current'?}
CheckDB -- yes --> ParseDB[Parse + verify signature]
ParseDB -- valid --> CacheDB[Memory cache<br/>active license]
ParseDB -- invalid --> NoLicense
CheckDB -- no --> NoLicense[No license<br/>protected interfaces blocked]
Priority 1 — PERFSHOP_LICENSE_KEY environment variable. This is the recommended mode in classrooms or automated deployments: the key is injected at docker compose up time and the instance starts with its license already active. Example:
# Unix / macOS
echo "PERFSHOP_LICENSE_KEY=PFSH-xxx.yyy" >> .env
# Windows PowerShell
Add-Content .env "PERFSHOP_LICENSE_KEY=PFSH-xxx.yyy"
Priority 2 — perfshop_license database table. Used when the license is activated after the fact via the UI (instructor panel → license management → paste the key → Activate) or via POST /api/license/activate. A single record is kept, always with id = 'current'.
If neither source provides a valid and non-expired license, the cache remains null and the LicenseInterceptor blocks protected interfaces.
The three plans¶
| Plan | Unlocks | Target |
|---|---|---|
functional |
chaos-admin, admin, monitoring, scripts-ui | Functional testing teams, chaos engineering without load |
performance |
Everything in functional + jmeter-ui |
Performance engineering teams |
enterprise |
Full access (all current and future features) | Large organizations, multi-team use |
Pricing and commercial terms are not documented here — contact contact@perfshop.io to obtain a license.
The hasFeature(String feature) method of LicenseService implements this logic:
return switch (info.plan) {
case "enterprise" -> true;
case "performance" -> !feature.equals("unavailable");
case "functional" -> List.of("chaos-admin", "admin", "monitoring", "scripts-ui").contains(feature);
default -> false;
};
HTTP interception — LicenseInterceptor¶
LicenseInterceptor is a Spring MVC HandlerInterceptor that intercepts all HTTP requests and decides whether to let them through or respond with 402 Payment Required.
Always-allowed paths¶
These paths are never blocked, regardless of the license:
/api/license/*— activation, status, revocation (otherwise deadlock: impossible to activate without being authenticated)/api/chaos/student/*— the freemium student page must remain accessible/api/chaos/public/*— read-only pedagogical monitoring/api/products,/api/auth/,/api/cart/,/api/checkout/,/api/orders,/api/countries— the e-commerce shop remains free/actuator/*— Prometheus must be able to scrape/images/*— product assets
Paths protected by plan¶
For other paths, the interceptor identifies the required feature:
| URL prefix | Required feature |
|---|---|
/api/jmeter/* |
jmeter-ui |
/api/admin/* |
admin |
/api/chaos/* (except /student/ and /public/) |
chaos-admin |
/api/scripts/* |
scripts-ui |
/api/monitoring/* |
monitoring |
If the license does not unlock the requested feature, the response is:
HTTP/1.1 402 Payment Required
Content-Type: application/json;charset=UTF-8
X-License-Required: true
{
"error": "LICENSE_REQUIRED",
"message": "License required to access chaos-admin",
"path": "/api/chaos/backend",
"activateUrl": "/api/license/activate",
"statusUrl": "/api/license/status",
"portalUrl": "https://perfshop.io"
}
The X-License-Required: true header allows HTTP clients to quickly detect this case.
Public management endpoints¶
LicenseController exposes three public endpoints (without authentication):
GET /api/license/status¶
Returns the current license status. Always available. Used by all UIs (chaos-admin, scripts-ui, jmeter-ui) to display the state even before authenticating the user.
{
"valid": true,
"plan": "performance",
"planLabel": "🎯 Performance Testing",
"holder": "XYZ School",
"issuedAt": "2026-01-15",
"expiresAt": "2027-01-15",
"unlimited": false,
"daysRemaining": 180,
"features": ["chaos-admin", "admin", "monitoring", "scripts-ui", "jmeter-ui"]
}
If no license is active, the response is:
POST /api/license/activate¶
Activates a license key. Callable without authentication — this is necessary for the first deployment, otherwise deadlock (you cannot log in without a license). Security is ensured by the RSA-PSS verification of the key.
POST /api/license/activate
Content-Type: application/json
{ "licenseKey": "PFSH-eyJsaWNlbnNlSWQi...xyz" }
Possible responses:
200 OK+{ success, message, status }— success400 Bad Request— empty key or invalid format (does not start withPFSH-)422 Unprocessable Entity— invalid signature, expired key, or unknown plan
POST /api/license/revoke¶
Revokes the active license. Marks revoked = 1 in the database and empties the memory cache. Also public — without it, it would be impossible to revoke an expired license without accessing the database directly. Returns 409 Conflict if no license is active.
Activation diagram¶
sequenceDiagram
autonumber
participant U as User
participant C as chaos-admin UI
participant B as LicenseController
participant S as LicenseService
participant DB as MySQL
U->>C: Pastes key PFSH-xxx.yyy
C->>B: POST /api/license/activate
B->>S: activateLicense(key)
S->>S: parseLicense → RSA-PSS check
alt Valid signature
S->>DB: UPDATE perfshop_license SET licence_key=...
S->>S: cachedLicense.set(info)
S-->>B: ActivationResult.success
B-->>C: 200 { success, status }
C-->>U: ✅ License activated
else Invalid signature
S-->>B: ActivationResult.error
B-->>C: 422 { error }
C-->>U: ❌ Invalid key
end
perfshop_license table schema¶
The table is created by V1__schema.sql (see Database schema).
CREATE TABLE perfshop_license (
id VARCHAR(36) NOT NULL DEFAULT 'current' PRIMARY KEY,
license_key TEXT NOT NULL,
plan VARCHAR(20) NOT NULL,
holder VARCHAR(255) NOT NULL,
issued_at DATE NOT NULL,
expires_at DATE NULL,
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked TINYINT(1) NOT NULL DEFAULT 0,
CONSTRAINT chk_plan CHECK (plan IN ('functional', 'performance', 'enterprise'))
);
A single record at a time (constraint id = 'current'). The upsert in activateLicense() first performs an UPDATE, then an INSERT if no row existed.
Obtaining a license¶
To obtain a PerfShop commercial license, contact contact@perfshop.io. Licenses are issued for durations of one year or for unlimited duration depending on the plan negotiated. Licenses include access to the lab library sold separately and hosted on the perfshop.io portal.