Staging — environnement isolé

PPROD Central — remise de clé de contenu

Implémentation de staging de la partie serveur du brief « PPROD Central » : remise authentifiée de la clé de contenu (XChaCha20-Poly1305, 32 octets) d’un build chiffré de LeVoixPro à l’agent local, uniquement si le compte et la machine y ont encore droit.

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.

Les identifiants envoyés par le client (produit, build, révision, machine) ne sont jamais une preuve d’autorisation : seules la signature du JWT de licence et l’état actuel du compte en base font foi.
MéthodeCheminRôle
POST/api/v1/central/content-keyRemise de la clé dans une enveloppe signée
GET/api/v1/central/jwks.jsonClé publique des enveloppes (JWKS)
GET/api/v1/central/keys/{kid}.pemMême clé au format PEM
GET/api/v1/central/healthÉtat (booléens uniquement, 503 si KO)
GET/api/v1/central/openapi.yamlContrat OpenAPI 3.1
POST/api/userMiroir staging de l’activation LeLicence (JWT de licence)
GET/api/v1/staging/licence-jwks.jsonClé publique des JWT de licence staging
POST/api/v1/staging/admin/*Administration des comptes de test

Parcours

  1. Activation
    POST /api/user avec email + mot de passe (ou jeton plugin) et l’identifiant de machine.
  2. JWT de licence
    Certificat RS256 LICENCE+JWT signé par le serveur de licences staging, lié au produit et à la machine.
  3. Demande de clé
    POST /api/v1/central/content-key, JWT en Bearer, identifiants du build et nonce frais.
  4. Vérification
    L’agent vérifie l’enveloppe avec la clé publique épinglée : signature, typ, aud, identifiants, nonce, exp.
  5. 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.

StatutHTTPCas
VALID200Droit actif, machine enregistrée (ou nouvelle dans la limite des places).
ERROR_BAD_CREDENTIALS403Mot de passe ou jeton faux, compte inconnu ou désactivé.
ERROR_NO_LICENSE403Aucun droit sur ce produit (ou seulement révoqués). Pas de Démo automatique (comportement Store désactivé pour la recette).
ERROR_DEMO_EXPIRED403Licence expirée — nom historique du Store, valable pour tous les types.
ERROR_MAX_LICENCE403Nouvelle machine au-delà du plafond du compte (une machine déjà connue ne consomme pas de place).
ERROR_SUBSCRIPTION_PAYMENT_FAILED403Abonnement past_due, unpaid ou canceled.
ERROR_PRODUCT_INVALID403Produit inconnu.
ERROR_INVALID_MACHINE403Staging 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.

Le JWT de licence contient le jeton de revalidation (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éma Bearer insensible à 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

ChampTypeRègle
productIdchaîne^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
buildIdchaîne^[A-Za-z0-9][A-Za-z0-9._:+-]{0,127}$
blobIdentier JSON0 … 2147483647 ; une chaîne "3" ou 3.0 est refusée
keyRevisionchaîne^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
machineIdchaînenon vide après trim, ≤ 190 octets, UTF-8 valide, sans caractère de contrôle
requestNoncechaînebase64 standard strict (pas base64url) de exactement 24 octets aléatoires, nouveau à chaque requête

Ordre de traitement (le premier échec l’emporte)

  1. HTTPS, puis méthode POST (HTTPS_REQUIRED, METHOD_NOT_ALLOWED).
  2. Quota par adresse IP (RATE_LIMITED + Retry-After).
  3. En-tête Authorization présent et bien formé (UNAUTHENTICATED + WWW-Authenticate: Bearer realm="pprod-central").
  4. Type de contenu, taille, JSON, champs (UNSUPPORTED_MEDIA_TYPE, INVALID_REQUEST).
  5. Vérification du JWT de licence : RS256 seul, typ LICENCE+JWT, kid de confiance, signature, iss, exp présent et futur (tolérance 60 s), iat présent et d’âge borné, pas de claim error, status = VALID ; aud et product = productId.
  6. machineId de la requête = claim machineId du JWT (comparaison exacte, à temps constant).
  7. Quota par compte.
  8. Droits actuels du compte : compte désactivé ou jeton plugin tourné, licence révoquée, expirée, abonnement impayé, machine retirée…
  9. 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.
  10. Clé lue dans le coffre chiffré ; en cas d’échec, SERVICE_UNAVAILABLE, jamais de clé de secours.
  11. Enveloppe signée, événement d’audit granted écrit avant la réponse (sinon SERVICE_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="
}
Exemple factice : la clé (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, machineId et requestNonce sont 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-store et 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"
}
CodeHTTPSignification
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

Ces URL sont une facilité de distribution. L’agent doit épingler la clé publique attendue (ou son empreinte SHA-256) dans l’application, et ne jamais la découvrir dynamiquement depuis le serveur qui signe : un attaquant capable de servir des réponses pourrait aussi servir sa propre clé. Une rotation de clé d’enveloppe passe par une nouvelle version de l’agent qui épingle les deux clés pendant la transition.

Durées et règles hors ligne

ObjetDuréeRègle
Ticket hors ligne = exp du JWT de licence (tous types)24 h iciexp = 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é = licenceExpselon l’achatnull = achat perpétuel (tickets renouvelables) ; sinon fin de la Démo, de la licence Temporaire ou de la période d’abonnement.
Certificat de refus300 sValidité technique seulement, status ≠ VALID : n’accorde rien.
Enveloppe de clé≤ 300 sexp = 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’exp de 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 que productId, buildId, blobId, keyRevision, machineId et requestNonce sont 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).

EmailSituationAttendu
central.ok@example.invalidPayée à vie, 2 places, aucune machineVALID puis clé exacte (200).
central.sans-licence@example.invalidAucun droit LeVoixProERROR_NO_LICENSE, aucune clé.
central.expire@example.invalidDroit terminéERROR_DEMO_EXPIRED (licence expirée), aucune clé.
central.machine@example.invalid1 place, occupée par la machine AA : VALID ; toute autre machine : ERROR_MAX_LICENCE.
central.court@example.invalidPayée à vie, ticket de 120 sVALID 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.

HandleEmailÉtatComportement attendu
okcentral.ok@pprod-temp.testPayée à vie, plafond 3 machinesClé délivrée (200).
abocentral.abo@pprod-temp.testAbonnement actif (+30 j)Clé délivrée (200).
abo-impayecentral.abo-impaye@pprod-temp.testAbonnement past_due (+10 j)Activation refusée ; remise : SUBSCRIPTION_INACTIVE.
democentral.demo@pprod-temp.testDémo (+14 j)Clé délivrée pendant la démo.
expirecentral.expire@pprod-temp.testTemporaire expirée (−1 j)Activation refusée ; remise : ENTITLEMENT_EXPIRED.
revoquecentral.revoque@pprod-temp.testPayée à vie, compte désactivéActivation refusée ; remise : ACCOUNT_DISABLED.
cap1central.cap1@pprod-temp.testPayée à vie, plafond 1 machine2ᵉ machine refusée à l’activation (ERROR_MAX_LICENCE) puis à la remise (MACHINE_NOT_AUTHORIZED).
courtcentral.court@pprod-temp.testPayée à vie, ticket de 60 sClé délivrée, puis LICENCE_TOKEN_EXPIRED une fois exp passé (tolérance serveur 60 s).
autrecentral.autre@pprod-temp.testPayée à vie sur LeVoixAutreSon 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.

RouteCorpsEffet
/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).

Refusées à l’import : les clés triviales (moins de 8 valeurs d’octet distinctes) et toute clé dont l’empreinte figure dans la liste noire 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épondent BUILD_UNSUPPORTED.
  • Destruction (irréversible) : central:key:destroy --product=P --revision=R --force efface 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

État sur cette instance : activée . Depuis le 22/09/2026, les trois routes V2 exigent la preuve HMAC du client, et la révision du kit (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.

  1. Challenge
    POST /api/v2/central/devices/challenge : licence, machine et recipient (algorithme, keyId, SPKI). Réponse signée PPROD-DEVICE-CHALLENGE+JWT contenant un secret chiffré pour cette clé (120 s, usage unique).
  2. Preuve
    L’agent vérifie le JWS (type, kid, audience, identifiants, nonce, exp) puis déchiffre le secret avec son matériel.
  3. Liaison
    POST /api/v2/central/devices/complete avec la preuve → 204. La machine est liée à cette clé ; une autre clé est refusée (DEVICE_KEY_CONFLICT).
  4. Ticket
    Revalidation /api/user : la licence porte le claim signé deviceKeyId, lu dans le registre du serveur.
  5. Remise
    POST /api/v2/central/content-key (+ deviceKeyId) → PPROD-CONTENT-KEY-V2+JWT avec wrappedKeyJwe, jamais contentKeyBase64.

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 (+ epk P-256 pour ECDH) ; ni zip, ni crit, ni jku.
  • 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-library 4.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 sans deviceKeyId (à 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.

Le challenge et la remise produisent des JWE du même profil : l’agent vérifie le 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-Type exactement application/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), clientAuthKeyId et clientAuthRequestHash (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/complete garde 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 :

  1. À 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).
  2. À 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é du jti (cache anti-rejeu).
  3. 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.