Skip to content

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

HeaderFormatDescription
X-Stormeo-API-KeystringClé API en clair (validée par validatePluginApiKey)
X-Stormeo-Signaturesha256=<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 depuis site_connectors.apiSecret (chiffré at-rest AES-256-GCM)
  • timestamp : entier Unix en secondes (time() PHP, time.time() Python, Date.now() / 1000 JS)
  • 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 :

  1. HMAC_REQUIRED=1 env → REQUIRED partout (kill-switch global)
  2. Route avec { requireSignature: true } → REQUIRED sur cette route
  3. pluginVersion >= seuil platform → REQUIRED automatiquement (auto-rollout par version)
  4. Sinon → OPTIONAL (legacy accepté + warning log)

Seuils par défaut (overridable via env HMAC_THRESHOLD_<PLATFORM>=X.Y.Z) :

PlatformSeuil
wordpress3.1.0
prestashop2.6.0

Conséquence : si vous mettez à jour votre plugin vers >= seuil, vous devez signer toutes les requêtes.

Erreurs renvoyées

HTTPMessageCause
401Missing X-Stormeo-Signature/Timestamp headers (HMAC required)Plugin >= seuil mais headers absents
401Invalid X-Stormeo-TimestampTimestamp non parseable
401Timestamp skew exceeds 5min toleranceHorloge plugin désync
401Invalid signature format (expected sha256=<hex>)Préfixe sha256= manquant
401Invalid signature hexPas 64 caractères hex valides
401Invalid signatureMismatch HMAC (timing-safe compare)

Implémentation — exemple PHP (WordPress)

php
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_post qui 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)

php
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) :

javascript
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.rawBody AVANT le parsing JSON :

js
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; }}));

Test local

Reproduire la signature en CLI :

bash
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

StormeoOS API