Ouverture prochaine — les auteurs gardent 100 % de leur prix Devenir auteur Se connecter Créer un compte
Bonjour, identifiez-vous Compte et listes EspaceAuteur

Licences, documentation développeur

Comment vérifier une clé de licence à l’intérieur de votre produit : la bibliothèque, l’API, des intégrations prêtes pour onze environnements, et les règles qui gardent les sites de vos acheteurs en marche.

1.Ce que cela fait, et ce que cela ne fait pas

Une clé de licence lie votre produit à un domaine, ou à autant de domaines que l’acheteur en a payés. Elle permet à un client honnête de rester dans ce qu’il a acheté, elle vous donne le compte de vos installations, et elle rend une clé fuitée tuable — son propriétaire la régénère et celle qui a fuité meurt. Ce n’est pas une protection contre la copie. Votre acheteur a vos sources et peut retirer le contrôle en cinq minutes. Quiconque prétend le contraire vous vend quelque chose.

2.Trois étapes

  1. Ouvrez votre produit dans votre espace auteur, allez dans Licence, et téléchargez la bibliothèque. Elle contient déjà notre clé publique et l’identifiant de votre produit — rien à configurer.
  2. Demandez sa clé à l’acheteur à la première installation et rangez-la là où votre produit garde ses réglages. Ne l’écrivez jamais en dur.
  3. Appelez valide() là où cela compte. Une fois par requête suffit — la bibliothèque met en cache et n’ira pas sur le réseau à chaque page.

3.Votre bibliothèque n’est celle de personne d’autre

Le fichier que vous téléchargez est généré pour un produit. La classe, le nom de fichier et le fichier de cache portent tous un nom dérivé de ce produit, et l’adresse appelée est assemblée à l’exécution plutôt qu’écrite d’un bloc. Deux de vos propres produits ne partagent pas un seul identifiant.

Soyons clairs sur ce que cela vous achète. Qui ouvre vos fichiers et les lit trouve le contrôle en deux minutes — cela n’a pas changé et ne changera jamais. Ce que cela arrête, c’est l’autre chose : un script qui cherche un nom de classe connu dans des milliers de produits et le retire tout seul. C’est ainsi que les copies nullées se fabriquent en série, et cela ne marche plus ici. Le coût du piratage de votre produit passe de rien à un examen manuel, produit par produit.

Les exemples de cette page appellent la classe Licence. Dans votre téléchargement, elle porte son vrai nom, affiché à côté du bouton de téléchargement dans votre espace auteur.

4.L’intégration, environnement par environnement

Ouvrez celui qui correspond à votre produit. Chaque bloc est complet et prêt à coller ; renommez monmodule et adaptez le rangement de la clé à vos propres réglages. Les environnements PHP utilisent la bibliothèque à télécharger ; les autres montrent le client complet, cache et vérification de signature compris.

PHP PHP sans cadriciel SDK PHP 5.6 → 8.4

La forme la plus courte. Placez le contrôle à l’entrée de ce que vous protégez — un contrôleur frontal, une page d’administration, un script planifié.

monproduit.php
require_once __DIR__ . '/Licence.php';

$licence = new Licence(
    $config['licence'],
    __DIR__ . '/cache'
);

if (!$licence->valide()) {
    http_response_code(403);
    exit($licence->message());
}
PS PrestaShop 1.7 et 8 SDK 1.7 · 8 · 9

Gardez une seule instance pour tout le module. Contrôlez dans getContent() pour que le marchand voie pourquoi l’écran de configuration refuse, et dans vos hooks d’affichage pour qu’un module resté à moitié configuré ne s’affiche pas en boutique.

monmodule.php
require_once dirname(__FILE__) . '/Licence.php';

class MonModule extends Module
{
    private $licence = null;

    private function licence()
    {
        if ($this->licence === null) {
            $this->licence = new Licence(
                Configuration::get('MONMODULE_LICENCE'),
                _PS_CACHE_DIR_ . 'monmodule'
            );
        }
        return $this->licence;
    }

    public function getContent()
    {
        if (Tools::isSubmit('submitLicence')) {
            Configuration::updateValue('MONMODULE_LICENCE', Tools::getValue('licence'));
        }

        $l = $this->licence();

        if (!$l->valide()) {
            return $this->displayError($l->message()) . $this->formulaireLicence();
        }
        if ($l->essai()) {
            return $this->displayWarning($this->l('Development licence')) . $this->contenu();
        }

        return $this->contenu();
    }

    public function hookDisplayHeader()
    {
        if (!$this->licence()->valide()) {
            return '';
        }
        return $this->contenuFront();
    }
}

Vérifié sur 1.7, 8 et 9. Les appels utilisés — Module, Configuration, Tools, _PS_CACHE_DIR_ — ont traversé les suppressions de la 9.0 sans être touchés. Les dépréciations annoncées pour la 10.0 concernent les contrôleurs d’administration, que cet exemple n’utilise pas.

WP WordPress SDK 5.0 → 7.1

Une instance statique dans une fonction, un avis dans l’administration, et le corps de l’extension derrière le contrôle. Ne faites pas le contrôle sur le front de chaque page — le cache le rend peu coûteux, mais un avis a sa place dans l’administration.

monplug.php
require_once __DIR__ . '/Licence.php';

function monplug_licence() {
    static $l = null;
    if ($l === null) {
        $l = new Licence(
            get_option('monplug_licence'),
            WP_CONTENT_DIR . '/cache/monplug'
        );
    }
    return $l;
}

add_action('admin_notices', function () {
    $l = monplug_licence();
    if (!$l->valide()) {
        printf('<div class="notice notice-error"><p>%s</p></div>', esc_html($l->message()));
    } elseif ($l->essai()) {
        echo '<div class="notice notice-warning"><p>'
           . esc_html__('Development licence', 'monplug') . '</p></div>';
    }
});

add_action('init', function () {
    if (monplug_licence()->valide()) {
        monplug_demarrer();
    }
});

Les cinq appels utilisés — add_action, add_filter, get_option, esc_html, WP_CONTENT_DIR — n’ont pas bougé depuis WordPress 5.0 et fonctionnent toujours sur la branche 7.1. Rien ici ne dépend de l’éditeur de blocs ni d’une route REST.

Pour WooCommerce, conditionnez ce que vous ajoutez à la boutique plutôt que l’extension entière : une passerelle de paiement qui disparaît est plus claire pour le marchand qu’une extension qui meurt en silence.

woocommerce.php
add_filter('woocommerce_payment_gateways', function ($passerelles) {
    if (monplug_licence()->valide()) {
        $passerelles[] = 'WC_Gateway_MonPlug';
    }
    return $passerelles;
});

add_action('woocommerce_admin_field_monplug_licence', function () {
    $l = monplug_licence();
    if (!$l->valide()) {
        echo '<div class="error inline"><p>' . esc_html($l->message()) . '</p></div>';
    }
});
M2 Magento 2 SDK 2.4

Enveloppez la bibliothèque dans un modèle et injectez-la où vous en avez besoin. Le dossier de cache doit être celui de Magento, sinon un déploiement effacera votre verdict à chaque mise en production.

Model/Licence.php
namespace Editeur\MonModule\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Filesystem\DirectoryList;

class Licence
{
    private $licence;

    public function __construct(ScopeConfigInterface $config, DirectoryList $dossiers)
    {
        $this->licence = new \Licence(
            (string) $config->getValue('monmodule/general/licence'),
            $dossiers->getPath(DirectoryList::CACHE) . '/monmodule'
        );
    }

    public function valide(): bool
    {
        return $this->licence->valide();
    }

    public function message(): string
    {
        return $this->licence->message();
    }
}

La bibliothèque n’a pas d’espace de noms : déclarez-la dans le composer.json de votre module plutôt que d’appeler require_once à la main.

composer.json
{
  "autoload": {
    "files": [
      "Licence.php"
    ]
  }
}
LV Laravel SDK 9 → 12

Un middleware est la bonne place : enregistrez-le sur le groupe de routes qui appartient à votre produit, jamais globalement, pour qu’un souci de licence n’emporte jamais le reste de l’application.

app/Licence/VerifieLicence.php
namespace App\Licence;

use Closure;
use Illuminate\Http\Request;

class VerifieLicence
{
    private $licence;

    public function __construct()
    {
        $this->licence = new \Licence(
            config('monmodule.licence'),
            storage_path('app/monmodule')
        );
    }

    public function handle(Request $requete, Closure $suivant)
    {
        if (!$this->licence->valide()) {
            abort(403, $this->licence->message());
        }

        return $suivant($requete);
    }
}
SF Symfony SDK 5.3 → 7

Un abonné d’événement sur kernel.request, qui ignore les sous-requêtes. Passez la clé et le dossier de cache en arguments dans votre définition de service plutôt que de lire la configuration dans la classe.

src/Licence/LicenceSubscriber.php
namespace App\Licence;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class LicenceSubscriber implements EventSubscriberInterface
{
    private $licence;

    public function __construct(string $cle, string $dossierCache)
    {
        $this->licence = new \Licence($cle, $dossierCache);
    }

    public function onKernelRequest(RequestEvent $evenement): void
    {
        if (!$evenement->isMainRequest()) {
            return;
        }

        if (!$this->licence->valide()) {
            throw new AccessDeniedHttpException($this->licence->message());
        }
    }

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::REQUEST => ['onKernelRequest', 16]];
    }
}

isMainRequest() est arrivée dans Symfony 5.3. Sur une version antérieure, appelez isMasterRequest() à la place — tout le reste est identique.

JS Node.js API Node 18+

Aucune dépendance au-delà de la bibliothèque standard. Voici le client complet : réponse signée, cache lié à son hôte, réutilisation 24 heures et sursis de 30 jours — les mêmes règles que la bibliothèque PHP.

licence.mjs
import { createPublicKey, verify } from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';

const API = 'https://addonsmarket.com/api/licence';
const PUBLIQUE = '/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=';
const SURSIS = 2592000;

const publique = createPublicKey({
  key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'),
                      Buffer.from(PUBLIQUE, 'base64')]),
  format: 'der',
  type: 'spki',
});

const canonique = (d) =>
  JSON.stringify(Object.fromEntries(Object.keys(d).sort().map((k) => [k, d[k]])));

const signee = (rep, hote) =>
  rep && rep.donnees && rep.signature
  && rep.donnees.hote === hote
  && verify(null, Buffer.from(canonique(rep.donnees), 'utf8'), publique,
            Buffer.from(rep.signature, 'base64'));

export async function etat(cle, hote, fichierCache) {
  const maintenant = Math.floor(Date.now() / 1000);
  let cache = null;

  try {
    const lu = JSON.parse(await readFile(fichierCache, 'utf8'));
    if (signee(lu, hote)) cache = lu;
  } catch {}

  if (cache && cache.donnees.revalider > maintenant) return cache.donnees;

  try {
    const r = await fetch(API, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'User-Agent': 'MonProduit/1.0' },
      body: JSON.stringify({ cle, url: hote }),
      signal: AbortSignal.timeout(6000),
    });
    if (!r.ok) throw new Error(String(r.status));

    const rep = await r.json();
    if (!signee(rep, hote)) throw new Error('signature');

    await mkdir(dirname(fichierCache), { recursive: true });
    await writeFile(fichierCache, JSON.stringify(rep));
    return rep.donnees;
  } catch {
    if (cache && cache.donnees.emis + SURSIS > maintenant) return cache.donnees;
    return { ok: false, motif: 'reseau' };
  }
}

Node 18 ou plus récent : fetch est devenu global en 18, et AbortSignal.timeout en 17.3. Sur une version antérieure, remplacez ces deux-là par https.request et votre propre minuteur.

Appelez-le ensuite une fois par requête, et ne bloquez jamais sur une panne réseau.

serveur.mjs
import { etat } from './licence.mjs';

const d = await etat(process.env.LICENCE, req.hostname, './var/licence.json');

if (!d.ok && d.motif !== 'reseau') {
  return res.status(403).json({ erreur: d.motif });
}
UI React, Vue, Angular API

Il n’existe pas de façon honnête de vérifier une licence dans un navigateur. Ce que vous écrivez tourne sur la machine de l’acheteur, dans du code qu’il peut lire, et le retirer prend une ligne dans les outils de développement. Nous ne fournissons pas de bibliothèque front parce que ce serait vous vendre un placebo.

Si votre produit a un serveur, contrôlez là-bas et exposez le verdict comme une donnée. L’interface peut alors dire quelque chose d’utile sans que le contrôle lui-même soit dans le navigateur.

serveur.mjs
app.get('/api/etat', async (req, res) => {
  const d = await etat(process.env.LICENCE, req.hostname, './var/licence.json');
  res.json({ actif: d.ok, essai: d.essai });
});
Licence.jsx
const [actif, setActif] = useState(null);

useEffect(() => {
  fetch('/api/etat')
    .then((r) => r.json())
    .then((d) => setActif(d.actif));
}, []);

if (actif === false) {
  return <Bandeau>Licence inactive</Bandeau>;
}

Si votre produit est un gabarit front sans aucun serveur, il n’y a rien à contrôler à l’exécution et il ne faut pas faire semblant. La licence s’applique là où elle le peut : au téléchargement, et par les conditions que votre acheteur a acceptées.

SH Shopify API

Une application Shopify a son propre serveur : utilisez-y le client Node ou PHP, et envoyez le domaine de la boutique — l’adresse myshopify.com, ou le domaine personnalisé si c’est sur lui que vous vous appuyez. Un thème Shopify n’a pas de serveur : c’est la section front ci-dessus qui s’applique.

PY Python API Python 3.7+

Bibliothèque standard, plus cryptography pour la signature. Mêmes règles qu’ailleurs : réutilisation 24 heures, conservation du dernier verdict 30 jours si nous nous taisons, blocage seulement sur un refus signé.

licence.py
import base64, json, time, urllib.request
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

API = 'https://addonsmarket.com/api/licence'
PUBLIQUE = '/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw='
SURSIS = 2592000

_publique = Ed25519PublicKey.from_public_bytes(base64.b64decode(PUBLIQUE))


def _canonique(d):
    return json.dumps(d, sort_keys=True, separators=(',', ':'), ensure_ascii=False)


def _signee(rep, hote):
    try:
        if rep['donnees']['hote'] != hote:
            return False
        _publique.verify(base64.b64decode(rep['signature']),
                         _canonique(rep['donnees']).encode('utf-8'))
        return True
    except (KeyError, TypeError, ValueError, InvalidSignature):
        return False


def etat(cle, hote, fichier_cache):
    maintenant = int(time.time())
    chemin = Path(fichier_cache)
    cache = None

    try:
        lu = json.loads(chemin.read_text(encoding='utf-8'))
        if _signee(lu, hote):
            cache = lu
    except (OSError, ValueError):
        pass

    if cache and cache['donnees']['revalider'] > maintenant:
        return cache['donnees']

    try:
        corps = json.dumps({'cle': cle, 'url': hote}).encode('utf-8')
        requete = urllib.request.Request(API, data=corps, headers={
            'Content-Type': 'application/json',
            'User-Agent': 'MonProduit/1.0',
        })
        with urllib.request.urlopen(requete, timeout=6) as r:
            rep = json.loads(r.read().decode('utf-8'))

        if not _signee(rep, hote):
            raise ValueError('signature')

        chemin.parent.mkdir(parents=True, exist_ok=True)
        chemin.write_text(json.dumps(rep), encoding='utf-8')
        return rep['donnees']

    except Exception:
        if cache and cache['donnees']['emis'] + SURSIS > maintenant:
            return cache['donnees']
        return {'ok': False, 'motif': 'reseau'}
{ } HTTP brut API

Pour tout le reste — Go, Ruby, .NET, Java, un script shell. Un POST, un corps JSON. Voyez la section API plus bas pour les champs, et la section signature pour ce que vous devez vérifier avant de faire confiance à la réponse.

curl
curl -X POST https://addonsmarket.com/api/licence \
     -H 'Content-Type: application/json' \
     -H 'User-Agent: MonProduit/1.0' \
     -d '{"cle":"VOTRE_CLE","url":"boutique.exemple.fr"}'

5.Le dire plutôt que casser

Pour un thème ou un script vendu à quelqu’un qui n’est pas développeur, couper le site sur une copie non licenciée est le mauvais geste : la personne qui en souffre est en général l’acheteur qui a mal recopié sa clé, et c’est votre boîte de support qui paie. banniere() renvoie à la place une petite mention fixe — rien d’autre ne change sur la page.

index.php
require_once __DIR__ . '/Licence.php';

$reglages = @include __DIR__ . '/licence.php';

$licence = new Licence(
    is_array($reglages) ? $reglages['cle'] : '',
    __DIR__ . '/cache'
);

register_shutdown_function(function () use ($licence) {
    echo $licence->banniere('My theme');
});

L’acheteur la fait disparaître en mettant sa clé dans un fichier, à côté de la bibliothèque. Ce fichier est la seule chose qu’il ait à toucher.

licence.php
<?php

return array(
    'cle' => '',
);

Elle renvoie une chaîne vide quand la licence est valable, et aussi quand le réseau a échoué — un visiteur ne voit jamais de mention parce que notre serveur a eu une mauvaise nuit. Sur une clé de développement elle le dit, discrètement, ce qui est exactement ce qu’il faut pendant que vous construisez.

6.Les produits sans serveur : thèmes HTML, gabarits, graphismes

Un thème HTML, un fichier Figma, un jeu d’icônes, une police : rien de tout cela ne tourne sur un serveur que vous pouvez interroger. Tout ce que vous livrez est lu, modifié et redistribuable par qui détient les fichiers. Il n’y a pas de contrôle à ajouter, et il n’existe aucune version de cette page où cela change.

Ne faites pas semblant. Un script obscurci qui appelle le serveur depuis une page statique, une empreinte cachée dans un canvas, une version qui se casse au bout de trente jours : tout cela se retire en un après-midi par la seule personne qui allait vous pirater, et tout cela finit en ticket de support chez les cinquante qui ont payé. Vous dépenseriez votre crédibilité pour ne rien protéger.

Ce qui existe à la place agit à la livraison, pas à l’exécution. Chaque téléchargement est reconditionné pour l’acheteur qui l’a demandé, et porte trois choses :

  • Un fichier de licence à la racine de l’archive, qui nomme la commande, le titulaire et la portée de la licence.
  • Une empreinte, dans ce fichier et dans le commentaire de l’archive — vingt caractères dérivés de la licence et du produit, qui identifient ce téléchargement-là et aucun autre.
  • Une phrase, en deux langues, qui dit ce que coûte la redistribution : les droits d’usage et les mises à jour, sans remboursement.

C’est de la traçabilité, pas de la prévention, et nous ne vous la vendrons pas pour autre chose : un acheteur qui supprime le fichier et vide le commentaire repart avec une copie propre. Ce qu’elle vous donne, c’est un nom quand une copie apparaît là où elle ne devrait pas, une raison pour cet acheteur d’y réfléchir à deux fois, et les deux choses qu’une copie piratée n’a jamais — les mises à jour et votre support.

Une distinction compte ici. Si votre thème contient du PHP, ne serait-ce qu’un formulaire de contact, un fichier de configuration ou un include, alors il tourne sur un serveur, le contrôle ci-dessus est réel et la mention fonctionne. Seul le cas entièrement statique, du HTML et du CSS et rien d’autre, n’offre aucun contrôle à l’exécution.

7.La bibliothèque

Un seul fichier PHP, aucune dépendance, compatible jusqu’à PHP 5.6 pour tourner sur les vieilles installations que vos acheteurs ont encore. Quatre méthodes suffisent.

new Licence($cle, $dossierCache) Construisez-la avec la clé de l’acheteur et un dossier inscriptible pour le cache.
$licence->valide() Vrai quand le produit peut fonctionner sur ce domaine.
$licence->essai() Vrai sur une clé de développement ou un domaine de test. Affichez une mention discrète — ne livrez jamais une version qui la masque.
$licence->message() Une phrase à montrer à l’acheteur, déjà traduite, qui nomme les domaines et date l’événement.

8.La règle qui compte le plus

Ne laissez jamais un problème réseau couper la boutique de votre acheteur. La bibliothèque s’en charge déjà et vous ne devez pas la contourner : un verdict frais est réutilisé 24 heures ; quand notre serveur ne répond pas, le dernier verdict connu est conservé 30 jours et le produit continue de fonctionner ; seul un refus signé l’arrête, immédiatement. Si notre serveur a une mauvaise nuit, aucune boutique ne s’éteint. Si une clé est transférée, l’ancien site s’arrête sous 24 heures.

9.Les domaines

Une licence se lie au domaine enregistrable. Une clé sur exemple.fr couvre www.exemple.fr, boutique.exemple.fr et tout autre sous-domaine — votre acheteur ne vous écrira pas à cause d’une redirection manquante. Les adresses de test ne consomment jamais de licence : localhost, .local, .test, et les préfixes habituels dev., staging., preprod. fonctionnent sans rien consommer.

10.L’API, si vous préférez l’appeler vous-même

La bibliothèque n’est qu’une enveloppe autour d’un seul point d’entrée. Appelez-le directement si vous travaillez hors PHP — mais vous devrez alors implémenter vous-même les règles de cache ci-dessus, et vérifier la signature.

Point d’entrée

POST https://addonsmarket.com/api/licence

Requête

HTTP
POST /api/licence HTTP/1.1
Host: addonsmarket.com
Content-Type: application/json
User-Agent: MonProduit/1.0

{
  "cle": "VOTRE_CLE",
  "url": "boutique.exemple.fr"
}

Ce que vous envoyez

cle string La clé de l’acheteur. Lettres et chiffres seulement ; nous retirons le reste, donc les espaces et tirets collés sont sans effet.
url string L’hôte sur lequel tourne votre produit. Une URL complète fonctionne aussi — nous n’en gardons que l’hôte. Retirez vous-même le port et le www. si vous voulez que la réponse corresponde à ce que vous avez envoyé.

Réponse

JSON
{
  "donnees": {
    "ok": true,
    "motif": "",
    "domaine": "exemple.fr",
    "essai": false,
    "quand": "",
    "emis": 1790687453,
    "revalider": 1790773853,
    "sursis": 2592000,
    "hote": "boutique.exemple.fr"
  },
  "signature": "4p9JXJRfcB5R/LNEQwr/cwnVv1M5R1OdCNcU…"
}

Champs de la réponse

ok bool Si le produit peut fonctionner ici.
motif string Pourquoi non, quand ok vaut faux. Voir le tableau plus bas.
domaine string Le domaine auquel la licence est liée.
essai bool Clé de développement ou domaine de test.
quand string Quand la clé a été remplacée ou révoquée.
emis int Quand nous avons répondu. Le début du sursis de 30 jours.
revalider int Horodatage avant lequel vous ne devez pas redemander.
sursis int Secondes pendant lesquelles vous pouvez garder ce verdict si nous ne répondons plus.
hote string L’hôte sur lequel nous avons répondu. Rejetez la réponse si ce n’est pas le vôtre.

Codes HTTP

200 Un verdict signé. Notez qu’un refus est aussi un 200 — lisez ok, pas le code HTTP.
400 Clé ou hôte manquant.
405 Autre chose qu’un POST ou un OPTIONS.
429 Limite d’appels atteinte. Gardez votre verdict en cache et réessayez plus tard — ne traitez jamais ceci comme un refus.

Trente appels par minute et par adresse IP. Avec les règles de cache respectées, une boutique fait un appel par jour ; vous n’atteindrez donc cette limite que depuis un test de charge ou une boucle qui a oublié son cache.

Envoyez un User-Agent qui nomme votre produit. Quelques agents par défaut de bibliothèques HTTP — celui d’urllib en Python notamment — sont filtrés avant de nous parvenir et reçoivent un 403. N’importe quelle chaîne personnalisée l’évite.

11.Pourquoi la réponse est signée

Sans signature, une ligne dans le fichier hosts d’un serveur suffit à répondre « licence valide » à notre place. Chaque réponse porte une signature Ed25519 sur la forme canonique des données — clés triées, JSON compact. Vérifiez-la avant de faire confiance à quoi que ce soit. La bibliothèque s’en charge, y compris sur la copie en cache, elle-même liée à son hôte pour qu’on ne puisse pas la déplacer d’un site à l’autre.

12.Vérifier la signature vous-même

Utile seulement si vous appelez l’API directement. Reconstruisez la forme canonique à partir de l’objet analysé — jamais à partir des octets bruts reçus, qui peuvent différer — puis vérifiez la signature Ed25519 détachée avec notre clé publique.

Notre clé publique

Ed25519, 32 octets bruts en base64. Elle est la même pour tous les auteurs et tous les produits.

/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=

La forme canonique

La signature couvre l’objet donnees sérialisé selon ces quatre règles, et rien d’autre. Manquez-en une et toutes les signatures paraîtront invalides.

  1. Clés triées par ordre croissant.
  2. Aucun blanc : pas d’espace après les deux-points ni les virgules.
  3. Les barres obliques ne sont pas échappées.
  4. Les caractères non ASCII restent en UTF-8, sans échappement antislash-u.

Pour la réponse ci-dessus, les octets exactement signés :

utf-8
{"domaine":"exemple.fr","emis":1790687453,"essai":false,"hote":"boutique.exemple.fr","motif":"","ok":true,"quand":"","revalider":1790773853,"sursis":2592000}
PHP PHP
sodium
function signature_valide(array $donnees, $signature, $publique)
{
    ksort($donnees);
    $corps = json_encode($donnees, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

    return sodium_crypto_sign_verify_detached(
        base64_decode($signature, true),
        $corps,
        base64_decode($publique, true)
    );
}
JS Node.js
node:crypto
import { createPublicKey, verify } from 'node:crypto';

const publique = createPublicKey({
  key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'),
                      Buffer.from('/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw=', 'base64')]),
  format: 'der',
  type: 'spki',
});

const canonique = (d) =>
  JSON.stringify(Object.fromEntries(Object.keys(d).sort().map((k) => [k, d[k]])));

const valide = verify(null, Buffer.from(canonique(rep.donnees), 'utf8'),
                      publique, Buffer.from(rep.signature, 'base64'));
PY Python
cryptography
import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

publique = Ed25519PublicKey.from_public_bytes(base64.b64decode('/PTkKQpweo9XCLOS0+b0vgrecx7Ly/SsAuwTiA737Tw='))

canonique = json.dumps(rep['donnees'], sort_keys=True,
                       separators=(',', ':'), ensure_ascii=False)

publique.verify(base64.b64decode(rep['signature']), canonique.encode('utf-8'))

13.Motifs de refus

autre_domaine La clé est valide mais appartient à un autre domaine. Dites à l’acheteur de la transférer depuis son compte.
transfert La clé a été déplacée vers un autre site et remplacée. L’acheteur en a une neuve.
regeneration L’acheteur a demandé une nouvelle clé. L’ancienne est morte définitivement.
revocation Nous avons révoqué la clé : abus, fraude, ou litige tranché.
expiree Une clé d’essai à durée limitée qui est arrivée à terme.
inconnue Cette clé n’existe pas. En général une faute de frappe.
reseau Nous n’avons pas pu être joints et aucun verdict en cache ne subsiste. Ne bloquez pas là-dessus.

14.Tester avant de vendre

Votre espace auteur vous donne une clé de développement pour chacun de vos produits. Elle fonctionne sur n’importe quel domaine, n’ouvre que vos propres produits, et signale toujours essai — une installation qui l’utilise affiche donc une mention de développement et ne peut jamais passer pour une copie vendue.

Pour voir comment votre produit se comporte quand il est refusé, envoyez une clé qui n’existe pas. Vous obtenez un vrai refus signé, identique à ce que donne une clé révoquée — vous pouvez donc vérifier votre écran d’erreur sans rien révoquer.

curl
curl -X POST https://addonsmarket.com/api/licence \
     -H 'Content-Type: application/json' \
     -H 'User-Agent: MonProduit/1.0' \
     -d '{"cle":"cettecleNexistePas000000","url":"boutique.exemple.fr"}'

{"donnees":{"ok":false,"motif":"inconnue", …},"signature":"…"}

15.Ce que vous devez dire à votre acheteur

Votre produit enverra à notre serveur le domaine sur lequel il tourne. C’est un traitement de données personnelles au sens du droit européen, et votre acheteur doit en être informé. Dites-le dans la description de votre produit et dans votre propre documentation. Nous conservons le domaine, et l’IP pendant douze mois pour arbitrer les litiges ; rien d’autre, et nous n’en vendons rien.

Quelque chose n’est pas clair ?

Écrivez-nous depuis votre espace auteur. Une question qu’il a fallu poser signifie en général qu’il manque un paragraphe à cette page.

Auteur