Objet et statut
Ce service tourne sur https://pprod-temp.n24g.tech. Il est autonome : sa propre base SQLite,
ses propres clés, ses propres comptes de test. Il n’accède jamais au Store PPROD de production
(ni à sa base, ni à sa clé privée licence-rsa-2025) et ne modifie en rien le comportement des plugins
déjà vendus. Les JWT de licence qu’il accepte sont ceux de son miroir staging (kid
pprod-central-staging-20260921), jamais ceux de production.
Il est aligné sur le kit de recette fourni par l’équipe Central (21/09/2026) : mêmes comptes de test,
même clé de contenu staging1 pour le build 0.1.106-staging1, et signature des JWT de licence
et des enveloppes avec la clé RSA jetable du kit (kid pprod-central-staging-20260921), dont
seule la clé publique est embarquée par le client staging. Les deux types de jetons restent distincts par
typ et aud. En production, une clé par usage.
| Méthode | Chemin | Rôle |
|---|---|---|
| POST | /api/v1/central/content-key | Remise de la clé dans une enveloppe signée |
| GET | /api/v1/central/jwks.json | Clé publique des enveloppes (JWKS) |
| GET | /api/v1/central/keys/{kid}.pem | Même clé au format PEM |
| GET | /api/v1/central/health | État (booléens uniquement, 503 si KO) |
| GET | /api/v1/central/openapi.yaml | Contrat OpenAPI 3.1 |
| POST | /api/user | Miroir staging de l’activation LeLicence (JWT de licence) |
| GET | /api/v1/staging/licence-jwks.json | Clé publique des JWT de licence staging |
| POST | /api/v1/staging/admin/* | Administration des comptes de test |
Parcours
- Activation
POST /api/useravec email + mot de passe (ou jeton plugin) et l’identifiant de machine. - JWT de licence
Certificat RS256LICENCE+JWTsigné par le serveur de licences staging, lié au produit et à la machine. - Demande de clé
POST /api/v1/central/content-key, JWT enBearer, identifiants du build et nonce frais. - Vérification
L’agent vérifie l’enveloppe avec la clé publique épinglée : signature,typ,aud, identifiants, nonce,exp. - Coffre local
L’agent enveloppe la clé dans son coffre lié au matériel (Secure Enclave / TPM) et jette le JWT d’enveloppe ; il déchiffre le blob (XChaCha20-Poly1305) pour le plugin.
Activation POST /api/user (miroir LeLicence)
Même contrat que le Store : formulaire URL-encoded (ou JSON), POST uniquement. Connexion :
product, email, pw, os, machine ; revalidation :
product, token, os, machine. Les champs de diagnostic
version et client_time sont acceptés et ignorés. La réponse est le JWT compact brut
(RS256, typ LICENCE+JWT, kid pprod-central-staging-20260921, iss PPROD,
aud = produit), Cache-Control: no-store ; HTTP 200 si VALID, 403 sinon.
Succès (claims)
iss, aud, iat, exp, product, products, machineId,
name, email, token, status = "VALID", version,
demo, licenceType, licenceExp
Jamais de claim error sur un succès. token est le jeton opaque de renouvellement géré par
le serveur (ni le mot de passe, ni le JWT). exp = fin du ticket hors ligne ;
licenceExp = fin du droit acheté (null = perpétuel) ; licenceType =
Démo, Payée, Temporaire, Offert ou Abonnement.
Refus (certificat signé, jamais VALID)
iss, aud, iat, exp = iat + 300 s, error = "1",
status = "ERROR_…", version, product
Un refus métier est un JWT fraîchement signé : le client distingue un vrai refus d’une panne réseau. Sa courte validité technique n’accorde aucun droit ; il ne contient ni jeton ni donnée de compte.
| Statut | HTTP | Cas |
|---|---|---|
VALID | 200 | Droit actif, machine enregistrée (ou nouvelle dans la limite des places). |
ERROR_BAD_CREDENTIALS | 403 | Mot de passe ou jeton faux, compte inconnu ou désactivé. |
ERROR_NO_LICENSE | 403 | Aucun droit sur ce produit (ou seulement révoqués). Pas de Démo automatique (comportement Store désactivé pour la recette). |
ERROR_DEMO_EXPIRED | 403 | Licence expirée — nom historique du Store, valable pour tous les types. |
ERROR_MAX_LICENCE | 403 | Nouvelle machine au-delà du plafond du compte (une machine déjà connue ne consomme pas de place). |
ERROR_SUBSCRIPTION_PAYMENT_FAILED | 403 | Abonnement past_due, unpaid ou canceled. |
ERROR_PRODUCT_INVALID | 403 | Produit inconnu. |
ERROR_INVALID_MACHINE | 403 | Staging uniquement : machine vide, trop longue ou avec caractères de contrôle. |
Le contrôle de la limite de machines et l’enregistrement de la machine sont atomiques (verrou par compte,
transaction, contrainte d’unicité). Changer le champ machine ne libère ni ne remplace une machine existante :
seul un retrait côté serveur le fait.
token) : c’est un secret. Central doit le
conserver protégé (trousseau / coffre), jamais en clair ni dans un journal. La remise de clé ne demande jamais le mot de passe.
Contrat POST /api/v1/central/content-key
Requête
POST https://pprod-temp.n24g.tech/api/v1/central/content-key
Authorization: Bearer <JWT de licence>
Content-Type: application/json
{
"productId": "LeVoixPro",
"buildId": "0.1.106-staging1",
"blobId": 1,
"keyRevision": "staging1",
"machineId": "4cc1f1939c0b0911325f1533b01e1781ef9136f3d971d649f4fad5c0133b575e",
"requestNonce": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYX"
}
- HTTPS obligatoire ; méthode POST uniquement.
Authorization: schémaBearerinsensible à la casse, un seul espace, jeton ≤ 8 Kio.Content-Type: application/json(paramètres tolérés, ex.; charset=utf-8), corps ≤ 4 Kio, JSON UTF-8 valide.- Objet JSON avec exactement les six champs ci-dessous : tout champ inconnu est refusé.
Validation des champs
| Champ | Type | Règle |
|---|---|---|
productId | chaîne | ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ |
buildId | chaîne | ^[A-Za-z0-9][A-Za-z0-9._:+-]{0,127}$ |
blobId | entier JSON | 0 … 2147483647 ; une chaîne "3" ou 3.0 est refusée |
keyRevision | chaîne | ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ |
machineId | chaîne | non vide après trim, ≤ 190 octets, UTF-8 valide, sans caractère de contrôle |
requestNonce | chaîne | base64 standard strict (pas base64url) de exactement 24 octets aléatoires, nouveau à chaque requête |
Ordre de traitement (le premier échec l’emporte)
- HTTPS, puis méthode POST (
HTTPS_REQUIRED,METHOD_NOT_ALLOWED). - Quota par adresse IP (
RATE_LIMITED+Retry-After). - En-tête
Authorizationprésent et bien formé (UNAUTHENTICATED+WWW-Authenticate: Bearer realm="pprod-central"). - Type de contenu, taille, JSON, champs (
UNSUPPORTED_MEDIA_TYPE,INVALID_REQUEST). - Vérification du JWT de licence : RS256 seul,
typLICENCE+JWT,kidde confiance, signature,iss,expprésent et futur (tolérance 60 s),iatprésent et d’âge borné, pas de claimerror,status=VALID;audetproduct=productId. machineIdde la requête = claimmachineIddu JWT (comparaison exacte, à temps constant).- Quota par compte.
- Droits actuels du compte : compte désactivé ou jeton plugin tourné, licence révoquée, expirée, abonnement impayé, machine retirée…
- Build — seulement maintenant, pour ne rien révéler à un anonyme : inconnu, révision ou blob incohérents, build non supporté ou révision retirée.
- Clé lue dans le coffre chiffré ; en cas d’échec,
SERVICE_UNAVAILABLE, jamais de clé de secours. - Enveloppe signée, événement d’audit
grantedécrit avant la réponse (sinonSERVICE_UNAVAILABLE, aucune clé envoyée), réponse 200.
Chaque refus à partir de l’étape 3 écrit un événement d’audit denied (code + raison interne, empreintes uniquement) ; aucun refus ne renvoie de clé. Les refus des étapes 1–2 (transport, quota IP) ne sont pas audités : c’est le flot que le quota absorbe.
Réponse 200
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=0, no-store, private
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Request-Id: 0123456789abcdef0123456789abcdef
{"envelope": "<JWS compact RS256>"}
Enveloppe PPROD-CONTENT-KEY+JWT
En-tête
{
"typ": "PPROD-CONTENT-KEY+JWT",
"alg": "RS256",
"kid": "pprod-central-staging-20260921"
}
Claims (exemple décodé)
{
"iss": "PPROD",
"aud": "PPROD-Central",
"sub": "acct_12",
"jti": "00000000-0000-4000-8000-000000000000",
"iat": 1790000000,
"exp": 1790000300,
"productId": "LeVoixPro",
"buildId": "0.1.106-staging1",
"blobId": 1,
"keyRevision": "staging1",
"machineId": "4cc1f1939c0b0911325f1533b01e1781ef9136f3d971d649f4fad5c0133b575e",
"requestNonce": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYX",
"contentAlg": "XChaCha20-Poly1305",
"contentKeyBase64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
AAAA…A=, 32 octets nuls) et le nonce (octets 0x00 à 0x17)
ne sont pas du matériel réel, et aucune signature d’exemple n’est valide. Ne jamais copier une vraie enveloppe
dans une documentation, un ticket ou un journal.
aud=PPROD-Central: audience dédiée, distincte de celle des JWT de licence.exp=min(iat + 300 s, exp du JWT de licence);jti= UUID v4.productId,buildId,blobId(entier),keyRevision,machineIdetrequestNoncesont recopiés de la requête validée.contentKeyBase64: base64 standard avec padding (44 caractères) des 32 octets de la clé.- L’enveloppe est signée, pas chiffrée : sa confidentialité repose sur HTTPS,
no-storeet l’absence de toute journalisation.
Erreurs
Toutes les erreurs sous /api/ (404 et 405 compris) sont en application/problem+json (RFC 9457),
avec les mêmes en-têtes no-store. Le corps ne contient aucun détail interne : la raison précise ne va
que dans l’audit, retrouvable par le requestId.
{
"type": "https://pprod-temp.n24g.tech/errors#MACHINE_NOT_AUTHORIZED",
"title": "This machine is not authorized for this licence.",
"status": 403,
"code": "MACHINE_NOT_AUTHORIZED",
"requestId": "0123456789abcdef0123456789abcdef"
}
| Code | HTTP | Signification |
|---|---|---|
INVALID_REQUEST |
400 | Corps ou paramètres invalides : JSON mal formé, champ inconnu ou manquant, type ou format incorrect, corps trop gros. |
UNSUPPORTED_MEDIA_TYPE |
415 | Content-Type absent ou différent de application/json. |
HTTPS_REQUIRED |
403 | Requête reçue en HTTP clair. |
NOT_FOUND |
404 | Ressource inconnue (route, kid de clé publique…). |
METHOD_NOT_ALLOWED |
405 | Méthode HTTP non autorisée (en-tête Allow). |
UNAUTHENTICATED |
401 | En-tête Authorization Bearer absent ou mal formé. |
LICENCE_TOKEN_INVALID |
401 | JWT de licence invalide : signature, algorithme, typ, kid, émetteur, certificat d’erreur, claims manquants… |
LICENCE_TOKEN_EXPIRED |
401 | JWT de licence expiré ou trop ancien : revalider la licence via /api/user. |
PRODUCT_MISMATCH |
403 | Le JWT n’a pas été émis pour ce produit (aud / product). |
ACCOUNT_DISABLED |
403 | Compte désactivé, introuvable ou jeton plugin révoqué / tourné. |
ENTITLEMENT_DENIED |
403 | Aucun droit actif sur ce produit (licence absente ou révoquée, démo non autorisée). |
ENTITLEMENT_EXPIRED |
403 | La licence de ce produit est expirée. |
SUBSCRIPTION_INACTIVE |
403 | Abonnement impayé, en défaut de paiement ou annulé. |
MACHINE_NOT_AUTHORIZED |
403 | Machine différente de celle du JWT, ou non (plus) enregistrée pour ce compte. |
BUILD_UNKNOWN |
404 | Build inconnu pour ce produit (répondu seulement après authentification). |
BUILD_MISMATCH |
409 | Révision de clé ou blob incohérents avec le build. |
BUILD_UNSUPPORTED |
410 | Build plus supporté (statut, date de fin) ou révision retirée / détruite : mettre à jour le produit. |
DEVICE_KEY_REQUIRED |
403 | A registered device key is required for this delivery. |
DEVICE_KEY_MISMATCH |
403 | The device key does not match the licence and the registered device. |
DEVICE_KEY_CONFLICT |
409 | Another device key is already bound to this machine, or this key to another machine. |
DEVICE_KEY_INVALID |
400 | The device public key description is invalid or unsupported. |
DEVICE_PROOF_INVALID |
403 | The device proof is invalid, expired or already used. |
CLIENT_AUTH_REQUIRED |
403 | The Central client proof (X-PPROD-Client-* headers) is required. |
CLIENT_AUTH_INVALID |
403 | The Central client proof is invalid. |
CLIENT_AUTH_REPLAY |
403 | The Central client proof was already used. |
CLIENT_AUTH_UNAVAILABLE |
503 | The Central client proof cannot be checked right now. |
RATE_LIMITED |
429 | Trop de requêtes (par IP ou par compte) : attendre Retry-After secondes. |
SERVICE_UNAVAILABLE |
503 | Coffre, KEK, clé de signature ou audit indisponible ; aucune clé envoyée. Réessayer après Retry-After. |
INTERNAL_ERROR |
500 | Erreur inattendue, sans détail ; la requête échoue fermée. |
RATE_LIMITED et SERVICE_UNAVAILABLE portent toujours Retry-After ; les réponses 401 portent WWW-Authenticate: Bearer realm="pprod-central".
Clés publiques
- JWKS :
https://pprod-temp.n24g.tech/api/v1/central/jwks.json - PEM :
https://pprod-temp.n24g.tech/api/v1/central/keys/pprod-central-staging-20260921.pem - Clé courante :
kid=pprod-central-staging-20260921,iss=PPROD, RS256.
Durées et règles hors ligne
| Objet | Durée | Règle |
|---|---|---|
Ticket hors ligne = exp du JWT de licence (tous types) | 24 h ici | exp = min(iat + TTL, fin du droit) : jamais au-delà de la fin réelle du droit (Démo, Temporaire, période d’abonnement). Renouvelable en ligne par la revalidation (token). Comptes du kit : 24 h, et 120 s pour central.court@example.invalid. Valeurs de recette ; proposition pour la production : 7 jours, à confirmer. |
Droit acheté = licenceExp | selon l’achat | null = achat perpétuel (tickets renouvelables) ; sinon fin de la Démo, de la licence Temporaire ou de la période d’abonnement. |
| Certificat de refus | 300 s | Validité technique seulement, status ≠ VALID : n’accorde rien. |
| Enveloppe de clé | ≤ 300 s | exp = min(iat + 300 s, exp du JWT de licence) : durée de remise, pas d’usage. |
- L’enveloppe ne prolonge ni la licence ni la politique hors ligne : une licence expirée doit être revalidée, une enveloppe expirée est jetée.
- Chaque remise contrôle l’état actuel du compte : une révocation bloque immédiatement les nouvelles remises. Une machine hors ligne, elle, ne l’apprend qu’à sa prochaine revalidation ; au pire elle garde l’usage jusqu’à l’
expde son ticket. - Côté agent (brief du 21/09) : le coffre n’est pas une horloge, la clé ne s’autodétruit pas ; le contrôle de licence (ticket non expiré, tolérance client comprise) précède chaque nouvelle remise de moteur ; une instance déjà ouverte continue jusqu’à sa fermeture.
- Démo : une Démo active reçoit la clé (le plugin fonctionne pendant la démo).
- L’agent ne conserve que la clé enveloppée par son coffre matériel ; il ne sauvegarde jamais le JWT d’enveloppe tel quel ni la clé en clair (ni disque, ni journal).
- Avant usage, l’agent vérifie : signature avec la clé épinglée,
alg= RS256,typ=PPROD-CONTENT-KEY+JWT,iss,aud=PPROD-Central,exp, et queproductId,buildId,blobId,keyRevision,machineIdetrequestNoncesont exactement ceux qu’il a envoyés. - Une enveloppe expirée, rejouée (nonce inattendu) ou pour un autre build est rejetée sans exception.
Comptes de test (staging)
Comptes du kit Central (référence de recette)
Importés tels quels depuis seed.json (hash bcrypt du kit, sans re-hachage). Mot de passe de laboratoire commun :
celui du kit, déjà connu de l’équipe Central — il ne figure jamais sur cette page ni dans les journaux.
Contenu : build 0.1.106-staging1, blob 1, révision staging1. Machines fictives du kit :
A = 5d86…f1, B = 4cc1…5e (SHA-256 synthétiques).
| Situation | Attendu | |
|---|---|---|
central.ok@example.invalid | Payée à vie, 2 places, aucune machine | VALID puis clé exacte (200). |
central.sans-licence@example.invalid | Aucun droit LeVoixPro | ERROR_NO_LICENSE, aucune clé. |
central.expire@example.invalid | Droit terminé | ERROR_DEMO_EXPIRED (licence expirée), aucune clé. |
central.machine@example.invalid | 1 place, occupée par la machine A | A : VALID ; toute autre machine : ERROR_MAX_LICENCE. |
central.court@example.invalid | Payée à vie, ticket de 120 s | VALID avec exp = iat + 120 ; renouvelable en ligne ; au-delà, LICENCE_TOKEN_EXPIRED à la remise. |
Comptes complémentaires
Produits LeVoixPro et LeVoixAutre. Emails central.<handle>@pprod-temp.test, même mot de passe
de laboratoire que le kit. Scénarios absents du kit : abonnement, démo, compte désactivé, autre produit, rotation de clé.
Builds de LeVoixPro : stg-1.0.0 → stg-r1, stg-1.1.0 → stg-r2 (rotation),
stg-0.9.0 → stg-r1 non supporté ; blobs 1 = AU arm64, 2 = AU x86_64, 3 = VST3 arm64, 4 = VST3 x86_64.
| Handle | État | Comportement attendu | |
|---|---|---|---|
ok | central.ok@pprod-temp.test | Payée à vie, plafond 3 machines | Clé délivrée (200). |
abo | central.abo@pprod-temp.test | Abonnement actif (+30 j) | Clé délivrée (200). |
abo-impaye | central.abo-impaye@pprod-temp.test | Abonnement past_due (+10 j) | Activation refusée ; remise : SUBSCRIPTION_INACTIVE. |
demo | central.demo@pprod-temp.test | Démo (+14 j) | Clé délivrée pendant la démo. |
expire | central.expire@pprod-temp.test | Temporaire expirée (−1 j) | Activation refusée ; remise : ENTITLEMENT_EXPIRED. |
revoque | central.revoque@pprod-temp.test | Payée à vie, compte désactivé | Activation refusée ; remise : ACCOUNT_DISABLED. |
cap1 | central.cap1@pprod-temp.test | Payée à vie, plafond 1 machine | 2ᵉ machine refusée à l’activation (ERROR_MAX_LICENCE) puis à la remise (MACHINE_NOT_AUTHORIZED). |
court | central.court@pprod-temp.test | Payée à vie, ticket de 60 s | Clé délivrée, puis LICENCE_TOKEN_EXPIRED une fois exp passé (tolérance serveur 60 s). |
autre | central.autre@pprod-temp.test | Payée à vie sur LeVoixAutre | Son JWT LeVoixAutre présenté pour LeVoixPro : PRODUCT_MISMATCH. Une activation sur LeVoixPro : ERROR_NO_LICENSE. |
API d’administration staging
Désactivée (404) tant que STAGING_ADMIN_TOKEN_SHA256 est vide. Authentification
Authorization: Bearer <jeton admin>, comparé à temps constant à son empreinte SHA-256 configurée.
POST + JSON uniquement : les emails voyagent dans le corps, jamais dans l’URL.
| Route | Corps | Effet |
|---|---|---|
/api/v1/staging/admin/account | {email} | État du compte (sans hash ni jeton). |
/api/v1/staging/admin/account-state | {email, disabled?, maxMachines?, rotateToken?, tokenTtlSeconds?} | Désactiver, changer le plafond, tourner le jeton plugin, raccourcir la durée des JWT. |
/api/v1/staging/admin/licence-state | {email, product, type?, expiresAt?, subscriptionStatus?, revoked?} | Modifier la licence d’un produit (type, expiration, abonnement, révocation). |
/api/v1/staging/admin/machines/revoke | {email, product?, machineId?} | Retirer une machine (toutes sans machineId). |
/api/v1/staging/admin/reset | {} | Remettre les comptes complémentaires dans leur état initial (mots de passe conservés). Les comptes du kit se remettent à zéro côté serveur : staging:kit:import --reset. |
Import des clés de contenu (procédure serveur)
Une révision de clé est produite par la chaîne de release (elle doit correspondre octet pour octet aux fichiers déjà chiffrés) puis importée ; le serveur ne génère ni ne régénère jamais une clé. Au repos, elle est chiffrée par une KEK (hors webroot) ; les commandes n’affichent jamais que des empreintes.
1. Importer une révision
# depuis l’entrée standard (jamais la clé en argument : elle finirait dans l’historique et la liste des processus)
php bin/console central:key:import --product=LeVoixPro --revision=r2026-10 --format=base64 < /chemin/securise/cle.b64
# ou depuis un fichier présent sur le serveur (mode 0600), à effacer ensuite
php bin/console central:key:import --product=LeVoixPro --revision=r2026-10 --format=raw --file=/chemin/securise/cle.bin
shred -u /chemin/securise/cle.bin
Ré-importer la même clé est idempotent ; une clé différente pour une révision existante est refusée (une révision est immuable).
CENTRAL_KEY_FINGERPRINT_DENYLIST — par exemple la clé jetable localtest1 déjà embarquée dans
d’anciennes copies de laboratoire, qui ne doit jamais devenir commerciale. Une révision mise en liste noire après coup n’est
plus remise (BUILD_UNSUPPORTED) et apparaît COMPROMISED dans central:key:list. Il suffit de
nous transmettre l’empreinte (non secrète) de la clé à bannir, jamais la clé.
2. Comparer l’empreinte
php bin/console central:key:list --product=LeVoixPro
Empreinte publique = 16 premiers hex de sha256("pprod-central/key-fingerprint/v1\0" ‖ clé). La chaîne de release
calcule la même valeur de son côté : les deux doivent être identiques, sans jamais échanger la clé.
3. Enregistrer les builds
php bin/console central:build:register --product=LeVoixPro --build=1.2.0 --revision=r2026-10 \
--blob=1 --blob=2 --blob=3 --blob=4 [--support-ends-at=2027-12-31T23:59:59Z]
php bin/console central:build:list --product=LeVoixPro
Un build est lié à une seule révision, de façon immuable, et déclare la liste exhaustive de ses blobs.
4. Rotation, fin de support, retrait
- Rotation : nouvelle révision + nouveaux builds ; les anciens builds gardent l’ancienne révision tant qu’ils sont supportés.
- Fin de support d’un build :
central:build:support --product=P --build=B --status=unsupported(ou--support-ends-at=…) →BUILD_UNSUPPORTED. - Retrait d’une révision :
central:key:retire --product=P --revision=R→ tous ses builds répondentBUILD_UNSUPPORTED. - Destruction (irréversible) :
central:key:destroy --product=P --revision=R --forceefface le chiffré.
Kit de recette Central
php bin/console staging:kit:import --seed=/etc/pprod-temp/kit/seed.json # idempotent
php bin/console staging:kit:import --seed=/etc/pprod-temp/kit/seed.json --reset # état initial du kit
Le fichier est entièrement validé avant toute écriture (structure, emails, hash, droits, keySha256 de chaque clé).
Audit
php bin/console central:audit:tail --limit=50
php bin/console central:audit:purge --older-than=90
L’audit ne contient que des identifiants publics et des empreintes HMAC (machine, IP, JWT) : jamais de clé, jeton, mot de passe ni nonce.
V2 — remise chiffrée par machine
staging1) est réservée à la V2 : la route V1 répond DEVICE_KEY_REQUIRED pour ses builds.
La V2 ne renvoie plus jamais de clé de moteur lisible : le serveur la chiffre (JWE) pour la clé publique de machine enregistrée — Secure Enclave P-256 sur Mac, TPM RSA-2048 prévu sous Windows. La clé privée ne quitte jamais le matériel et le serveur ne la reçoit jamais.
- Challenge
POST /api/v2/central/devices/challenge: licence, machine etrecipient(algorithme,keyId, SPKI). Réponse signéePPROD-DEVICE-CHALLENGE+JWTcontenant un secret chiffré pour cette clé (120 s, usage unique). - Preuve
L’agent vérifie le JWS (type,kid, audience, identifiants, nonce,exp) puis déchiffre le secret avec son matériel. - Liaison
POST /api/v2/central/devices/completeavec la preuve → 204. La machine est liée à cette clé ; une autre clé est refusée (DEVICE_KEY_CONFLICT). - Ticket
Revalidation/api/user: la licence porte le claim signédeviceKeyId, lu dans le registre du serveur. - Remise
POST /api/v2/central/content-key(+deviceKeyId) →PPROD-CONTENT-KEY-V2+JWTavecwrappedKeyJwe, jamaiscontentKeyBase64.
Profil JWE (PPROD-DEVICE-KEY+JWE)
- Compact, cinq segments base64url sans padding ; en-tête protégé
alg,enc=A256CBC-HS512,typ,kid=deviceKeyId,apu= nonce brut,apv= SHA-256 du SPKI (+epkP-256 pour ECDH) ; nizip, nicrit, nijku. ECDH-ES: clé éphémère par chiffrement, Concat KDF SHA-256 à deux tours (512 bits) ; segment « encrypted key » vide.RSA-OAEP-256: clé de 64 octets chiffrée en OAEP SHA-256 / MGF1 SHA-256 (256 octets).- Charge utile : exactement 32 octets. Tag = 32 premiers octets de HMAC-SHA512 ; authentifier avant de déchiffrer.
- Interopérabilité vérifiée côté serveur par deux implémentations indépendantes (déchiffreur RFC et Python
cryptography). À éviter :web-token/jwt-library4.2.3 (PHP), dont le Concat KDF ne fait qu’un tour pour ce profil.
Bascule d’une révision
php bin/console central:key:delivery --product=LeVoixPro --revision=<rev> --minimum-version=2
Elle ferme la remise brute V1 pour tous les builds de cette révision, anciens tickets compris
(DEVICE_KEY_REQUIRED) ; la V2 reste servie. Retour en arrière : --minimum-version=1 --allow-downgrade.
Codes d’erreur V2
DEVICE_KEY_REQUIRED(403) : aucune liaison utilisable, licence sansdeviceKeyId(à renouveler), ou V1 sur une révision réservée à la V2.DEVICE_KEY_MISMATCH(403) : licence, requête et registre ne désignent pas la même clé.DEVICE_KEY_CONFLICT(409) : machine déjà liée à une autre clé, ou clé liée à une autre machine.DEVICE_KEY_INVALID(400) : description de clé publique invalide ou non prise en charge.DEVICE_PROOF_INVALID(403) : preuve fausse, challenge inconnu, expiré, déjà utilisé ou d’un autre compte, produit, machine ou clé.CLIENT_AUTH_REQUIRED,CLIENT_AUTH_INVALID,CLIENT_AUTH_REPLAY(403),CLIENT_AUTH_UNAVAILABLE(503) : voir la preuve HMAC.
Réinitialisation d’une machine (nouvelle clé après réinstallation) : administrateur uniquement —
POST /api/v1/staging/admin/device-keys/revoke ou central:device:revoke. Aucune route publique
« forcer le remplacement ». Une réinitialisation n’est pas un bannissement : pour couper une machine volée, retirer la
machine, tourner le jeton plugin ou désactiver le compte.
typ du JWS
(PPROD-DEVICE-CHALLENGE+JWT ou PPROD-CONTENT-KEY-V2+JWT), l’audience, le kid, tous
les identifiants et son nonce avant de déchiffrer — jamais un secret de challenge pris pour une clé,
ni l’inverse. Une clé de contenu n’existe que sous une révision (empreinte unique) ; seuls P-256 non
compressé (0x04) et RSA-2048 avec e = 65537 sont acceptés.
V2 — preuve HMAC du client (obligatoire)
Une barrière contre un faux client qui s’inscrirait avec sa propre clé logicielle : chaque requête V2 porte un MAC calculé avec le secret de la version de Central. Ce secret est distinct des clés de contenu et de la clé de signature du serveur. C’est un durcissement, pas une attestation de l’application ou du matériel : la licence, les droits, la machine, la liaison et les challenges restent tous vérifiés, et un MAC valide n’accorde aucun droit.
- Portée : les trois routes V2, y compris pour une machine déjà inscrite. Aucun mode de compatibilité, aucune exemption. Contrôle placé après la licence, avant tout challenge, liaison ou remise.
- En-têtes (une fois chacun) :
X-PPROD-Client-Key-Id,X-PPROD-Client-Time(secondes Unix, 10 chiffres, ±60 s),X-PPROD-Client-Nonce(24 octets, 48 hex minuscules, usage unique),X-PPROD-Client-MAC(HMAC-SHA256, 64 hex).Content-Typeexactementapplication/json. - Texte signé, lignes terminées par LF (LF final compris) :
PPROD-CENTRAL-HMAC-1
<keyId>
POST
https://pprod-temp.n24g.tech<chemin exact, sans query string>
application/json
<timestamp>
<nonce>
<SHA-256 hex du JWT, sans « Bearer »>
<SHA-256 hex des octets exacts du corps>
- Anti-rejeu : après un MAC valide seulement, insertion unique de SHA-256(keyId LF nonce), conservée jusqu’à timestamp + 61 s, sur une connexion dédiée hors transaction : une demande acceptée consomme son nonce même si l’opération échoue ensuite ; un MAC faux ne consomme rien ; les entrées survivent aux redémarrages.
- Confirmation signée : les JWS de challenge et de remise ajoutent
clientAuthVersion(1),clientAuthKeyIdetclientAuthRequestHash(SHA-256 du texte signé), calculés par le serveur. Central les vérifie après la signature RS256 et avant tout déchiffrement.devices/completegarde son 204. - Erreurs :
CLIENT_AUTH_REQUIRED(en-tête manquant),CLIENT_AUTH_INVALID(MAC, version inconnue ou retirée, horodatage, valeur mal formée ou répétée),CLIENT_AUTH_REPLAY(nonce déjà vu) — 403 ;CLIENT_AUTH_UNAVAILABLE(503) : configuration ou stockage anti-rejeu indisponible, la demande est refusée, jamais acceptée. - Versions : un registre de secrets par version de Central, hors du dossier web. Retirer un identifiant bloque ses demandes futures sur les trois routes ; cela ne rappelle ni les clés déjà remises ni les tickets hors ligne encore valides.
Coffre local et preuve de possession
Le coffre matériel de l’agent (Secure Enclave + trousseau sur Mac, TPM prévu sous Windows) protège le stockage
de la clé ; sa clé de protection reste locale, non exportable, et le serveur n’a pas à la connaître. En revanche la
remise réseau n’est pas encore liée cryptographiquement à cette clé matérielle : le machineId reste
une information déclarée, contrôlée contre les activations enregistrées. Le certificat Developer ID Apple sert à signer le
logiciel : il n’est ni une clé de contenu ni un secret d’API serveur.
La V2 (ci-dessus) livre la preuve de possession à l’enregistrement et le chiffrement de la remise pour la clé enregistrée. Reste une option non livrée : une preuve signée à chaque requête :
- À l’activation, l’agent crée une paire P-256 non exportable (Secure Enclave / TPM) et envoie sa clé publique (JWK, et une
attestation si disponible) ; le serveur l’enregistre sur la ligne machine et ajoute au JWT de licence
cnf: {"jkt": "<empreinte RFC 7638>"}(RFC 7800). - À chaque remise, l’agent ajoute un en-tête de preuve de type DPoP (RFC 9449) : JWS ES256 sur
{htm, htu, iat, jti, requestNonce, productId, buildId, blobId, keyRevision}; le serveur vérifie la signature avec la clé enregistrée, la fraîcheur (± 60 s) et l’unicité dujti(cache anti-rejeu). - Retirer une machine revient à oublier sa clé publique. Enregistrement, attestation et recette Windows restent à définir.
Limites
Ce mécanisme contrôle la remise de la clé, pas ce qu’en fait un client que l’utilisateur contrôle. Après une activation légitime, un client sous le contrôle de l’utilisateur peut extraire le code déchiffré ou la clé de contenu. La révocation d’un compte, d’une licence ou d’une machine arrête les nouvelles remises, mais ne peut pas rappeler un secret déjà extrait.
Le machineId présent dans un JWT de licence signé et enregistré côté serveur n’est pas une
preuve de possession du matériel : c’est une valeur déclarée par le client, qu’un autre poste peut rejouer.
Étape suivante possible : la preuve de possession décrite plus haut.
La clé de contenu staging1 circule en clair dans le kit de recette : un build chiffré avec elle ne doit pas être
considéré comme confidentiel, ni distribué hors staging.