e-bon
e-bon.ro
Depanare

Nepotrivire de semnătură HMAC

De ce verificarea `X-EBon-Signature` eșuează pe endpoint-ul tău — drift de ceas, secret greșit după rotație și capcane la re-serializarea body-ului.

Când endpoint-ul tău calculează un HMAC diferit de cel pe care e-bon l-a trimis în X-EBon-Signature, aproape orice cauză e una din trei: ai re-serializat body-ul JSON înainte să-l hash-uiești, folosești secretul anterior după o rotație sau folosești pur și simplu secretul greșit (de ex. secretul webhook-ului de test pe webhook-ul de producție).

Nu există niciun cod de eroare pe partea e-bon pentru asta — endpoint-ul tău e cel care detectează nepotrivirea și răspunde non-2xx. e-bon tratează apoi răspunsul ca pe un eșec de livrare și reîncearcă conform programului din Eșecuri de livrare a webhook-urilor. Pe partea ta, loghează digestul calculat și X-EBon-Signature-ul primit, ca să-i poți compara byte cu byte.

Cauze probabile

  • Body JSON re-serializat — ai parsat cererea ca JSON și apoi ai re-stringificat-o înainte să o hash-uiești. Diferiți serializatori reordonează cheile, schimbă spațierea și reformatează numerele; hash-ul va diferi. Semnătura e peste bytes-urile brute ale body-ului cererii.
  • Secret greșit după rotațiePOST /api/v1/org/webhooks/{id}/rotate-secret întoarce o nouă valoare whsec_… o singură dată. Dacă ai rotit dar n-ai actualizat secretul stocat de endpoint, fiecare livrare ulterioară va eșua la verificare.
  • Secret greșit pentru webhook greșit — frecvent când copiezi-lipești secretul webhook-ului de test în endpoint-ul de producție, sau invers. Fiecare abonament are propriul secret.
  • Algoritm sau encoding greșit — algoritmul e HMAC SHA-256, digestul e hex cu litere mici, iar valoarea header-ului e literal string-ul sha256=<hex>. Hex (nu base64), litere mici, o singură linie.
  • Drift de ceasX-EBon-Timestamp e marcajul de timp al încercării de dispatch. Dacă respingi livrările pe o fereastră strânsă de skew (de ex. ±2 minute) și ceasul serverului tău s-a derivat, vei respinge cereri altfel valide. Semnătura în sine nu include timestamp-ul — drift-ul de ceas contează doar dacă adaugi peste o verificare anti-replay.

Cum verifici

Reproduce local pe un payload capturat folosind openssl:

# RAW_BODY = bytes-urile exacte primite în body-ul POST-ului, fără reformatare.
# SECRET   = valoarea whsec_… a webhook-ului tău.

printf '%s' "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print "sha256="$2}'

Rezultatul trebuie să fie egal byte cu byte cu valoarea header-ului X-EBon-Signature. Dacă nu e, body-ul pe care l-ai dat lui openssl diferă de cel pe care l-a hash-uit e-bon — aproape întotdeauna din cauza re-serializării.

Pentru a exclude un secret vechi, obține metadatele curente ale abonamentului. Secretul în sine nu e returnat, dar poți confirma enabled, events, url și failureCount:

curl https://api.e-bon.ro/api/v1/org/webhooks/{webhookId} \
  -H "Authorization: Bearer <jwt>"

Apoi trimite un eveniment de test controlat și compară cu cererea capturată:

curl -X POST https://api.e-bon.ro/api/v1/org/webhooks/{webhookId}/test \
  -H "Authorization: Bearer <jwt>"

Remediere

Capturează body-ul brut înainte ca orice framework să-l parseze

În Express, înregistrează un bodyParser.raw({ type: 'application/json' }) doar pe ruta de webhook, ca req.body să fie un Buffer. În alte framework-uri, găsește echivalentul — rawBody la Fastify, rawBody: true la NestJS, await request.body() la FastAPI. Hash-uiește acel buffer; nu citi întâi request.json() ca să re-stringifici după.

Actualizează secretul stocat după fiecare rotație

De fiecare dată când apelezi POST /api/v1/org/webhooks/{id}/rotate-secret, capturează noua valoare secret din răspuns și scrie-o în managerul tău de secrete (variabilă de mediu, vault, KMS) înainte să sosească următoarea livrare. Secretul anterior încetează să mai funcționeze în clipa în care rotația se termină.

Folosește o comparare în timp constant

Compară cele două string-uri cu crypto.timingSafeEqual (Node), hmac.compare_digest (Python), subtle.ConstantTimeCompare (Go) — niciodată cu ==. Asta previne canalele laterale de timing și prinde și bug-urile subtile de padding cu spații pe care == le-ar accepta în tăcere.

Dacă verifici skew-ul de timestamp, lărgește fereastra sau repară NTP-ul

Dacă respingi livrările cu |now - X-EBon-Timestamp| > N, asigură-te că ceasul serverului tău e în limita N față de UTC prin chronyc tracking sau timedatectl status. Recomandarea platformei e o fereastră de ±5 minute, nu ±30 de secunde — timeout-ul pe încercare e deja 10 s, dar reîncercările pot ajunge mult mai târziu.

Referința canonică de semnare, inclusiv exemplele Node și openssl folosite de însăși platforma: Evenimente webhook › Verifică semnătura webhook-ului. Forme de payload pe eveniment și header-e HTTP: Evenimente webhook.

Tot blocat?

Deschide un caz de suport la support@e-bon.ro sau e-bon.ro/contact cu webhookId-ul, un X-EBon-Delivery-Id capturat, header-ul X-EBon-Signature exact pe care l-ai primit și digestul calculat de endpoint-ul tău (nu e nevoie să trimiți secretul).