Signature HMAC — /api/plugin/v2/*
Pour prouver qu'une requête provient bien du plugin légitime (pas d'un attaquant qui aurait sniffé l'X-Stormeo-API-Key), les routes /api/plugin/v2/* exigent une signature HMAC-SHA256 sur le payload.
Source : server/middleware/pluginSignatureValidator.ts.
Headers requis
| Header | Format | Description |
|---|---|---|
X-Stormeo-API-Key | string | Clé API en clair (validée par validatePluginApiKey) |
X-Stormeo-Signature | sha256=<64 hex> | Signature HMAC-SHA256 du payload |
X-Stormeo-Timestamp | <unix-seconds> | Timestamp Unix au moment de l'émission |
Algorithme
signature = HMAC-SHA256(
key = apiSecret_decrypted,
data = "{timestamp}.{rawBody}"
)apiSecret: déchiffré côté serveur depuissite_connectors.apiSecret(chiffré at-rest AES-256-GCM)timestamp: entier Unix en secondes (time()PHP,time.time()Python,Date.now() / 1000JS)rawBody: chaîne brute envoyée dans le body (UTF-8). Utiliser le rawBody avant parsing JSON, sinon les espaces/ordre des clés peuvent diverger- Output : signature en hex lowercase, préfixée
sha256=
Anti-replay
Le serveur rejette toute requête dont le timestamp diffère de l'horloge serveur de plus de 300 secondes (5 min). Voir constante TIMESTAMP_TOLERANCE_SEC dans le validator.
→ Synchroniser l'horloge du plugin (NTP) si vous voyez des 401 Timestamp skew exceeds 5min tolerance.
Cascade d'activation
Le serveur applique la cascade suivante pour décider si HMAC est requis :
HMAC_REQUIRED=1env → REQUIRED partout (kill-switch global)- Route avec
{ requireSignature: true }→ REQUIRED sur cette route pluginVersion>= seuil platform → REQUIRED automatiquement (auto-rollout par version)- Sinon → OPTIONAL (legacy accepté + warning log)
Seuils par défaut (overridable via env HMAC_THRESHOLD_<PLATFORM>=X.Y.Z) :
| Platform | Seuil |
|---|---|
wordpress | 3.1.0 |
prestashop | 2.6.0 |
Conséquence : si vous mettez à jour votre plugin vers >= seuil, vous devez signer toutes les requêtes.
Erreurs renvoyées
| HTTP | Message | Cause |
|---|---|---|
401 | Missing X-Stormeo-Signature/Timestamp headers (HMAC required) | Plugin >= seuil mais headers absents |
401 | Invalid X-Stormeo-Timestamp | Timestamp non parseable |
401 | Timestamp skew exceeds 5min tolerance | Horloge plugin désync |
401 | Invalid signature format (expected sha256=<hex>) | Préfixe sha256= manquant |
401 | Invalid signature hex | Pas 64 caractères hex valides |
401 | Invalid signature | Mismatch HMAC (timing-safe compare) |
Implémentation — exemple PHP (WordPress)
function stormeo_sign_request($body_string, $api_secret) {
$timestamp = (string) time();
$signature = hash_hmac(
'sha256',
$timestamp . '.' . $body_string,
$api_secret
);
return [
'X-Stormeo-Signature' => 'sha256=' . $signature,
'X-Stormeo-Timestamp' => $timestamp,
];
}
// Usage
$body = wp_json_encode(['type' => 'security-event', 'data' => [...]]);
$headers = array_merge(
[
'X-Stormeo-API-Key' => get_option('stormeo_api_key'),
'Content-Type' => 'application/json',
],
stormeo_sign_request($body, $decrypted_api_secret)
);
wp_remote_post('https://beta.stormeo.io/api/plugin/v2/security-event', [
'headers' => $headers,
'body' => $body, // ⚠️ MÊME chaîne que celle signée
'timeout' => 30,
]);⚠️ Piège classique : passer un array PHP à
wp_remote_postqui le réencode → la chaîne signée diffère de celle envoyée. Toujours sérialiser une seule fois et signer cette chaîne.
Implémentation — exemple PHP (PrestaShop)
class StormeoCrypto {
const HMAC_TIMESTAMP_TOLERANCE = 300;
public static function signPayload($body, $apiSecret) {
$timestamp = (string) time();
$sig = hash_hmac('sha256', $timestamp . '.' . $body, $apiSecret);
return [
'X-Stormeo-Signature: sha256=' . $sig,
'X-Stormeo-Timestamp: ' . $timestamp,
];
}
}Implémentation — exemple Node.js (vérification côté receveur)
Pour recevoir un webhook signé depuis StormeoOS (cf. /v1/webhooks qui émet sortant) :
const crypto = require('crypto');
function verifyStormeoSignature(req, secret) {
const signature = req.headers['x-stormeo-signature']; // 'sha256=<hex>'
const timestamp = req.headers['x-stormeo-timestamp'];
if (!signature || !timestamp) return false;
// Anti-replay
const skew = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
if (skew > 300) return false;
if (!signature.startsWith('sha256=')) return false;
const provided = Buffer.from(signature.slice(7), 'hex');
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`) // utiliser le rawBody
.digest();
return provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
}Pour Express, il faut capturer
req.rawBodyAVANT le parsing JSON :jsapp.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; }}));
Test local
Reproduire la signature en CLI :
TIMESTAMP=$(date +%s)
BODY='{"type":"ping"}'
SECRET="<apiSecret>"
SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST \
-H "X-Stormeo-API-Key: <apiKey>" \
-H "X-Stormeo-Signature: sha256=$SIGNATURE" \
-H "X-Stormeo-Timestamp: $TIMESTAMP" \
-H "Content-Type: application/json" \
-d "$BODY" \
https://beta.stormeo.io/api/plugin/v2/site-info