Aller au contenu

Système de licence

PerfShop est publié sous licence duale : AGPL-3.0-or-later pour l'usage open source, et licence commerciale pour les usages qui ne peuvent pas satisfaire les obligations de l'AGPL (voir Licence). L'accès aux interfaces sensibles de la plateforme est conditionné à une clé de licence commerciale validée cryptographiquement. Cette page décrit le fonctionnement technique du système.

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)

Format de la clé

Une clé de licence PerfShop suit le format :

PFSH-<base64url_payload>.<base64url_signature>

Le préfixe PFSH- est un marqueur explicite qui permet une validation de forme immédiate (avant même d'appeler l'algorithme cryptographique). Le corps est constitué de deux segments séparés par un point :

  • payload — un objet JSON encodé en base64url contenant les informations de licence
  • signature — la signature RSA-PSS du payload, elle aussi encodée en base64url

Payload JSON

{
  "licenseId":  "uuid-v4",
  "holder":     "Nom du détenteur ou organisation",
  "plan":       "functional | performance | enterprise",
  "issuedAt":   "2026-01-15",
  "expiresAt":  "2027-01-15"
}

Le champ expiresAt peut être null pour une licence illimitée. Les dates sont au format ISO YYYY-MM-DD.

Algorithme cryptographique

La signature utilise RSA-PSS avec SHA-256, paramétré comme suit :

  • Clé publique : RSA 2048 bits (format SPKI / X.509 DER, embarquée dans LicenseService.java sous forme PEM)
  • Algorithme : RSASSA-PSS
  • Hash : SHA-256
  • MGF : MGF1 avec SHA-256
  • Salt length : 32 octets
  • 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));

La clé privée correspondante reste sur perfshop.io et ne quitte jamais le serveur de génération. Elle seule peut forger des licences valides. Une tentative de forger une clé sans accès à cette clé privée est calculatoirement infaisable (sécurité standard RSA 2048).

Protection d'intégrité de la clé publique

LicenseService.java embarque la clé publique en dur et stocke également le SHA-256 attendu de cette clé dans la constante EXPECTED_PUBLIC_KEY_HASH. Au démarrage (@PostConstruct init()), le service calcule le hash de la clé embarquée et le compare à la valeur attendue :

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");
    }
}

Si le hash ne correspond pas, la JVM refuse de démarrer. C'est un filet de sécurité contre une substitution de clé publique : un attaquant qui recompilerait PerfShop pour injecter sa propre clé publique (afin de forger ses propres licences) devrait aussi modifier EXPECTED_PUBLIC_KEY_HASH. Cette modification est détectable par diff et matérialise la dérivation du code source, ce qui déclenche les obligations de publication de l'AGPL.

Ce n'est pas une protection cryptographique — un attaquant déterminé peut toujours contourner ce check. C'est un garde-fou explicite qui rend toute altération visible et force la transparence.

Priorité de chargement

Au démarrage, LicenseService.loadLicense() tente de charger une licence dans cet ordre :

flowchart TD
  Start([Démarrage Spring Boot]) --> CheckEnv{PERFSHOP_LICENSE_KEY<br/>dans .env ?}
  CheckEnv -- oui --> ParseEnv[Parser + vérifier signature]
  ParseEnv -- valide --> CacheEnv[Cache mémoire<br/>licence active]
  ParseEnv -- invalide --> CheckDB
  CheckEnv -- non --> CheckDB{Ligne dans<br/>perfshop_license<br/>id='current' ?}
  CheckDB -- oui --> ParseDB[Parser + vérifier signature]
  ParseDB -- valide --> CacheDB[Cache mémoire<br/>licence active]
  ParseDB -- invalide --> NoLicense
  CheckDB -- non --> NoLicense[Aucune licence<br/>interfaces protégées bloquées]

Priorité 1 — variable d'environnement PERFSHOP_LICENSE_KEY. C'est le mode recommandé en salle de classe ou en déploiement automatisé : la clé est injectée au moment du docker compose up et l'instance démarre avec sa licence déjà active. Exemple :

# Unix / macOS
echo "PERFSHOP_LICENSE_KEY=PFSH-xxx.yyy" >> .env

# Windows PowerShell
Add-Content .env "PERFSHOP_LICENSE_KEY=PFSH-xxx.yyy"

Priorité 2 — table perfshop_license en base. Utilisée quand la licence est activée a posteriori via l'UI (panneau formateur → gestion licence → coller la clé → Activer) ou via POST /api/license/activate. Un seul enregistrement est conservé, toujours avec id = 'current'.

Si aucune des deux sources ne fournit une licence valide et non expirée, le cache reste null et le LicenseInterceptor bloque les interfaces protégées.

Les trois plans

Plan Débloque Cible
functional chaos-admin, admin, monitoring, scripts-ui Équipes de test fonctionnel, chaos engineering hors charge
performance Tout du plan functional + jmeter-ui Équipes de performance engineering
enterprise Accès complet (toutes features actuelles et futures) Grandes organisations, usage multi-équipes

Les tarifs et conditions commerciales ne sont pas documentés ici — contactez contact@perfshop.io pour obtenir une licence.

La méthode hasFeature(String feature) de LicenseService implémente cette logique :

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;
};

Interception HTTP — LicenseInterceptor

LicenseInterceptor est un HandlerInterceptor Spring MVC qui intercepte toutes les requêtes HTTP et décide de les laisser passer ou de répondre 402 Payment Required.

Chemins toujours autorisés

Ces chemins ne sont jamais bloqués, quelle que soit la licence :

  • /api/license/* — activation, statut, révocation (sinon deadlock : impossible d'activer sans être authentifié)
  • /api/chaos/student/* — la page étudiant freemium doit rester accessible
  • /api/chaos/public/* — monitoring pédagogique lecture seule
  • /api/products, /api/auth/, /api/cart/, /api/checkout/, /api/orders, /api/countries — le shop e-commerce reste libre
  • /actuator/* — Prometheus doit pouvoir scraper
  • /images/* — assets produits

Chemins protégés par plan

Pour les autres chemins, l'intercepteur identifie la feature requise :

Préfixe d'URL Feature requise
/api/jmeter/* jmeter-ui
/api/admin/* admin
/api/chaos/* (sauf /student/ et /public/) chaos-admin
/api/scripts/* scripts-ui
/api/monitoring/* monitoring

Si la licence ne débloque pas la feature demandée, la réponse est :

HTTP/1.1 402 Payment Required
Content-Type: application/json;charset=UTF-8
X-License-Required: true

{
  "error":       "LICENSE_REQUIRED",
  "message":     "Licence requise pour accéder à chaos-admin",
  "path":        "/api/chaos/backend",
  "activateUrl": "/api/license/activate",
  "statusUrl":   "/api/license/status",
  "portalUrl":   "https://perfshop.io"
}

Le header X-License-Required: true permet aux clients HTTP de détecter rapidement ce cas.

Endpoints publics de gestion

LicenseController expose trois endpoints publics (sans authentification) :

GET /api/license/status

Retourne le statut actuel de la licence. Toujours disponible. Utilisé par toutes les UI (chaos-admin, scripts-ui, jmeter-ui) pour afficher l'état avant même d'authentifier l'utilisateur.

{
  "valid":          true,
  "plan":           "performance",
  "planLabel":      "🎯 Performance Testing",
  "holder":         "École XYZ",
  "issuedAt":       "2026-01-15",
  "expiresAt":      "2027-01-15",
  "unlimited":      false,
  "daysRemaining":  180,
  "features":       ["chaos-admin", "admin", "monitoring", "scripts-ui", "jmeter-ui"]
}

Si aucune licence n'est active, la réponse est :

{
  "valid":     false,
  "plan":      "none",
  "planLabel": "Aucune licence",
  "message":   "Aucune licence active"
}

POST /api/license/activate

Active une clé de licence. Appelable sans authentification — c'est nécessaire pour le premier déploiement, sinon deadlock (on ne peut pas se connecter sans licence). La sécurité est assurée par la vérification RSA-PSS de la clé.

POST /api/license/activate
Content-Type: application/json

{ "licenseKey": "PFSH-eyJsaWNlbnNlSWQi...xyz" }

Réponses possibles :

  • 200 OK + { success, message, status } — succès
  • 400 Bad Request — clé vide ou format invalide (ne commence pas par PFSH-)
  • 422 Unprocessable Entity — signature invalide, clé expirée, ou plan inconnu

POST /api/license/revoke

Révoque la licence active. Marque revoked = 1 en base et vide le cache mémoire. Également public — sans cela, impossible de révoquer une licence expirée sans accéder à la base directement. Retourne 409 Conflict si aucune licence n'est active.

Diagramme d'activation

sequenceDiagram
  autonumber
  participant U as Utilisateur
  participant C as chaos-admin UI
  participant B as LicenseController
  participant S as LicenseService
  participant DB as MySQL

  U->>C: Colle clé PFSH-xxx.yyy
  C->>B: POST /api/license/activate
  B->>S: activateLicense(key)
  S->>S: parseLicense → vérif RSA-PSS
  alt Signature valide
    S->>DB: UPDATE perfshop_license SET licence_key=...
    S->>S: cachedLicense.set(info)
    S-->>B: ActivationResult.success
    B-->>C: 200 { success, status }
    C-->>U: ✅ Licence activée
  else Signature invalide
    S-->>B: ActivationResult.error
    B-->>C: 422 { error }
    C-->>U: ❌ Clé invalide
  end

Schéma de la table perfshop_license

La table est créée par V1__schema.sql (voir Schéma de la base de données).

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'))
);

Un seul enregistrement à la fois (contrainte id = 'current'). L'upsert de activateLicense() fait un UPDATE d'abord, puis un INSERT si aucune ligne n'existait.

Obtenir une licence

Pour obtenir une licence PerfShop commerciale, contactez contact@perfshop.io. Les licences sont délivrées pour des durées d'un an ou pour une durée illimitée selon le plan négocié. Les licences incluent l'accès à la bibliothèque de TP vendue séparément et hébergée sur le portail perfshop.io.