Authentification
L’API ExaTrust applique deux couches indépendantes :
- Signature applicative — obligatoire, sur chaque requête. Format RFC 9421 + Ed25519.
- mTLS de transport — optionnel, activé pour les partenaires telecom et infrastructure interne (hôte
partner.exatrust.cg). Géré par notre Nginx.
Les deux mécanismes sont indépendants : un appel mTLS sans signature applicative est refusé en 401, tout comme un appel signé mais sans cert client sur l’hôte mTLS.
flowchart LR A[Backend partenaire] -->|TLS + cert client| B[Nginx ExaTrust] B -->|HTTP + headers| C[Publication API] C -->|Vérif Ed25519| C C -->|Anti-replay Redis| C C -->|Rate-limit + crédits| C C -->|200 JSON| ASignature Ed25519 (RFC 9421)
Ce qui doit être signé
| Composant | Valeur | Notes |
|---|---|---|
@method | GET | Méthode HTTP. |
@path | /v1/me | Chemin sans query string. |
@query | ? ou ?foo=bar | Query string complète, avec le ?. Si pas de query, c’est ? (vide). |
Métadonnées obligatoires
| Paramètre | Valeur | Notes |
|---|---|---|
created | 1715680570 (Unix seconds) | Doit être au plus ±5 min de l’heure serveur. |
keyid | "pk_orange_8a3b" | Votre key_id exact, entre guillemets. |
alg | "ed25519" | Littéral. Seul algorithme accepté. |
nonce | "MmJ6sjJQRWQntzqjjLPpM0l9z8Onu2sDGNQpQO5woOM=" | 32 octets aléatoires en base64. Obligatoire en pratique : sans, deux requêtes dans la même seconde produisent la même signature et la seconde est rejetée en REPLAY_DETECTED. |
Le label de la signature doit être sig1 (sinon 400).
En-têtes envoyés
Vous produisez deux en-têtes HTTP :
Signature-Input: sig1=("@method" "@path" "@query"); created=1715680570; keyid="pk_orange_8a3b"; alg="ed25519"; nonce="MmJ6sjJQRWQntzqjjLPpM0l9z8Onu2sDGNQpQO5woOM="Signature: sig1=:WsV/LpsuuS5RVFA0u+xIG47oOHXjC6pyLuSSWVJspt4s2A8Djvt1GuoHxh8KItt2...:Le Signature-Input et le Signature-Params à l’intérieur de la base de signature doivent être strictement identiques.
Base de signature
Pour GET /v1/me, la base que vous signez est littéralement (chaque ligne séparée par \n) :
"@method": GET"@path": /v1/me"@query": ?"@signature-params": ("@method" "@path" "@query");created=1715680570;keyid="pk_orange_8a3b";alg="ed25519";nonce="MmJ6sjJQRWQntzqjjLPpM0l9z8Onu2sDGNQpQO5woOM="Vous signez cette chaîne en UTF-8 avec votre clé privée Ed25519, encodez en base64, et la mettez dans Signature: sig1=:<base64>: (les deux-points entourants font partie de la syntaxe).
Snippets
import ( "crypto/ed25519" "net/http"
"github.com/remitly-oss/httpsig-go" "github.com/remitly-oss/httpsig-go/keyutil" "github.com/remitly-oss/httpsig-go/sigtypes")
func signRequest(req *http.Request, privPath, keyID string) error { priv, err := keyutil.ReadPrivateKeyFile(privPath) if err != nil { return err } return httpsig.Sign(req, httpsig.SigningProfile{ Algorithm: sigtypes.Algo_ED25519, Fields: httpsig.Fields("@method", "@path", "@query"), Metadata: []httpsig.Metadata{ httpsig.MetaCreated, httpsig.MetaKeyID, httpsig.MetaAlgorithm, httpsig.MetaNonce, }, Nonce: httpsig.NonceRandom32, Label: "sig1", }, httpsig.SigningKey{ Key: priv.(ed25519.PrivateKey), MetaKeyID: keyID, }, )}Librairie : github.com/remitly-oss/httpsig-go (≥ v1.2, support RFC 9421).
import requestsfrom http_message_signatures import ( HTTPSignatureKeyResolver, HTTPMessageSigner, algorithms,)from cryptography.hazmat.primitives.serialization import load_pem_private_key
class StaticResolver(HTTPSignatureKeyResolver): def __init__(self, path: str): with open(path, "rb") as f: self.priv = load_pem_private_key(f.read(), password=None)
def resolve_private_key(self, key_id: str): return self.priv
def sign_and_send(method: str, url: str, key_id: str, priv_path: str): prepped = requests.Request(method, url).prepare() HTTPMessageSigner( signature_algorithm=algorithms.ED25519, key_resolver=StaticResolver(priv_path), ).sign( prepped, key_id=key_id, covered_component_ids=("@method", "@path", "@query"), label="sig1", ) return requests.Session().send(prepped)Librairie : http-message-signatures + cryptography + requests.
import { readFileSync } from "node:fs";import { createSigner, signMessage } from "http-message-signatures";
export async function signedHeaders(url, method, keyId, privPath) { const signer = createSigner(readFileSync(privPath), "ed25519", keyId); const signed = await signMessage( { key: signer, fields: ["@method", "@path", "@query"], name: "sig1", params: ["created", "keyid", "alg", "nonce"], }, { method, url, headers: {} }, ); return signed.headers;}Librairie : http-message-signatures (npm).
curl ne génère pas la signature lui-même. ExaTrust fournit un binaire Go signtest qui produit les en-têtes prêts à injecter :
# 1) Génère et exporte SIG_INPUT et SIG dans l'environnementeval "$(signtest \ -url 'https://partner.exatrust.cg/v1/resultats/candidats/EANB260094' \ -priv partner.priv.pem \ -kid pk_orange_8a3b \ -export)"
# 2) Appelcurl -sS \ -H "Signature-Input: $SIG_INPUT" \ -H "Signature: $SIG" \ https://partner.exatrust.cg/v1/resultats/candidats/EANB260094
# Tester l'anti-replay : rejoue trois fois la même signaturesigntest -url '...' -priv partner.priv.pem -kid pk_orange_8a3b -replay 3Modes d’échec courants
| Erreur | Cause | Correction |
|---|---|---|
400 The signature for label '' not found | Label ≠ sig1 | Forcer label: "sig1" dans la config de signature. |
401 INVALID_SIGNATURE — signature expired | created > 5 min écart | Synchroniser NTP. Signer juste avant l’envoi. |
401 REPLAY_DETECTED | Signature identique rejouée | Nouveau nonce aléatoire par requête. |
401 INVALID_KEY | keyid inconnu ou révoqué | Vérifier le key_id exact. Si révoqué, demander un nouveau. |
401 INVALID_SIGNATURE — could not verify | Clé publique côté serveur ≠ votre privée | Régénérer le keypair et retransmettre la publique. |
mTLS (optionnel)
Pour les partenaires recevant un certificat client mTLS, l’URL de base est https://partner.exatrust.cg/v1. Notre Nginx valide votre certificat contre notre CA avant même d’évaluer la signature applicative.
Ce que vous avez reçu
| Fichier | Format | Détention |
|---|---|---|
ca.crt | PEM, public | Pour vérifier notre serveur. |
<slug>.crt | PEM, signé par notre CA, valide 12 mois | Votre certificat client. |
<slug>.key | PEM, secret | Votre clé privée mTLS (différente de la clé Ed25519 de signature). |
Stockez <slug>.key à chmod 600, dans /etc/publication-api/certs/ ou équivalent. Sur Kubernetes, montez les trois fichiers via un Secret en lecture seule.
Utilisation par langage
import "crypto/tls"
cert, _ := tls.LoadX509KeyPair("partner.crt", "partner.key")client := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{cert}, }, },}s = requests.Session()s.verify = "ca.crt"s.cert = ("partner.crt", "partner.key")resp = s.send(prepped)import https from "node:https";
const agent = new https.Agent({ ca: readFileSync("ca.crt"), cert: readFileSync("partner.crt"), key: readFileSync("partner.key"),});
const resp = await fetch(url, { headers: signed.headers, agent });curl -sS \ --cacert ca.crt \ --cert partner.crt \ --key partner.key \ -H "Signature-Input: $SIG_INPUT" \ -H "Signature: $SIG" \ https://partner.exatrust.cg/v1/meErreurs mTLS courantes
| Erreur OpenSSL | Cause |
|---|---|
tlsv13 alert certificate required | Aucun certificat client envoyé. |
tlsv13 alert unknown ca | Certificat non signé par notre CA. |
certificate has expired | Renouvellement nécessaire (12 mois). |
L’erreur applicative correspondante côté Nginx :
{ "error": { "code": "NO_CLIENT_CERT", "message": "Certificat client mTLS requis ou invalide" }}ExaTrust vous contacte 30 jours avant l’expiration de votre certificat. La procédure de rollover consiste à déployer les nouveaux fichiers en parallèle, basculer la configuration, valider avec un GET /health, puis retirer l’ancien.