فشل التحقق من توقيع Webhook؟ اعرف السبب
خطأ فشل التحقق من توقيع webhook يعني شيئاً واحداً: البصمة التي حسبها كودك لا تساوي البصمة الموجودة في ترويسة الطلب. هذا كل ما تقوله الرسالة. لا علاقة لها بالصلاحيات، ولا بانتهاء المدة، ونادراً جداً أن تكون علّة في SDK المزوّد. هناك اختلاف بين البايتات التي حسب المزوّد تجزئتها والبايتات التي حسبت تجزئتها أنت.
أربعة مدخلات تحدّد النتيجة: أي بايتات وُقِّعت، وأي بايتات مفتاح استُخدمت، وأي خوارزمية تجزئة عملت، وبأي ترميز نصّي أجريت المقارنة. أخطئ في واحد منها فيظهر الفشل بالشكل نفسه تماماً. والرسالة لا تحمل أي إشارة إلى أيّها كان، فالمهمة هي تضييق فضاء المدخلات حتى يبقى واحد مشبوهاً.
اختر فرعاً للبداية:
التوقيع لا يتطابق؟ ثلاثة فروع:
├─ هل حلّل إطار العمل لديك JSON قبل أن تراه؟ → القسم 3
├─ هل تحمل قيمة الترويسة بادئة، أم تبدو مثل base64؟ → القسم 4
└─ هل تحتوي ترويسة المزوّد على طابع زمني؟ → القسم 2
1. ما الذي يخبرك به عدم تطابق التوقيع فعلاً
التحقّق مقارنة بين سلسلتَي بايتات. وحين يفشل، يكون الخطأ في واحد من أربعة أمور بالضبط، والأربعة مستقلّة بعضها عن بعض. والمصطلح العربي متفاوت بين المراجع: digest تُترجَم بصمةً وملخّصاً وهاشاً، وrawBody جسماً خاماً ونصَّ طلب خاماً. أمّا أسماء الترويسات ونصّ الخطأ فيبقيان بالإنجليزية كما وردا، فالبحث بالسلسلة الإنجليزية الحرفية يعطيك نتائج أوفر.
أي بايتات وُقِّعت؟ حسب المزوّد تجزئة تسلسل محدّد من البايتات. قد يكون جسم الطلب وحده، وقد يكون طابعاً زمنياً ملتصقاً بمقدّمة الجسم. وإن كان إطار العمل لديك قد حلّل JSON وسلّمك كائناً، فأنت لم تعد تملك تلك البايتات ولا تستطيع إعادة بنائها بثقة. هذا هو القسم 3، وهو السبب الأشيع بفارق كبير.
أي بايتات مفتاح استُخدمت؟ السلسلة السرّية الواحدة يمكن أن تُفسَّر نصّاً بترميز UTF-8، أو hex، أو base64، وكل قراءة تنتج مفتاحاً مختلفاً. وكذلك يفعل سرٌّ أبقى مُحمِّل الإعدادات فيه سطراً جديداً زائداً. وفي هذا البُعد فشل ثانٍ مستتر: قد يكون السرّ سرّاً خاطئاً من أصله، لا قراءةً خاطئة للسرّ الصحيح، وهذا موضوع القسم 6.
بأي ترميز أجريت المقارنة؟ البصمة 32 بايتاً خاماً في SHA-256. وhex وbase64 طريقتان لكتابة البايتات نفسها نصّاً، ولا تتشابهان أبداً. قارن إحداهما بالأخرى فتحصل على عدم تطابق دائم في توقيع HMAC رغم أن البايتات الأصلية متّفقة.
أي خوارزمية تجزئة عملت؟ معظم المزوّدين يستخدمون SHA-256 ويوثّقون ذلك، فهذا البُعد لا يكلّفك شيئاً في العادة. وGitHub هو الاستثناء الذي يستحقّ المعرفة: كل تسليم يحمل X-Hub-Signature (HMAC-SHA1) إلى جانب X-Hub-Signature-256 (HMAC-SHA256)، ووثائق GitHub نفسها تقول إن ترويسة SHA-1 «مُدرَجة لأغراض التوافق مع القديم فقط»، وتوصي بنسخة الـ256. اقرأ الخطأ منهما فيفضح الطولُ الأمر قبل البايتات. جسم القسم 2، موقَّعاً بالسرّ نفسه تحت SHA-1، يعطي sha1=ba2954d180839d8170b08b32cd38483775aaae96، أي 40 محرفاً بترميز hex مقابل 64 محرفاً في بصمته بـSHA-256.
أبقِ الأربعة منفصلة وأنت تُشخِّص. وأسرع طريقة لعزل بُعد واحد هي حساب البصمة خارج تطبيقك من مدخلات تسيطر عليها: الصق جسماً وسرّاً في مولّد HMAC وانظر ما تحصل عليه. تعمل الأداة بالكامل في متصفّحك ولا يخرج السرّ من الصفحة، فلا خطر في لصق سرّ توقيع إنتاجي فيها. يشغّل HMAC بدائية SHA-256 نفسها التي تشغّلها تجزئة SHA-256 المجرّدة، إنما بمفتاح هو سرّك. فإن أمكنك إعادة إنتاج قيمة المزوّد يدوياً، فالتعمية سليمة والعلّة في معالجتك للطلب.
2. ماذا يوقّع كل مزوّد من الأربعة الكبار
الافتراض الذي يكسر معظم التكاملات هو أن كل مزوّد يوقّع جسم الطلب ولا شيء غيره. اثنان من الأربعة الكبار لا يفعلان ذلك. وهذا ما يحسب كلٌّ منهم تجزئته، كما تنصّ عليه وثائق كل مزوّد:
| المزوّد | الترويسة | السلسلة المُوقَّعة | الترميز | بادئة القيمة | السرّ | هامش الطابع الزمني |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | سرّ توقيع نقطة النهاية (ببادئة whsec_) | 5 دقائق (300 ثانية) |
| GitHub | X-Hub-Signature-256 | rawBody (بلا بادئة) | hex | sha256= | رمز سرّ الـwebhook | لا شيء (لا يُرسَل طابع زمني) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | سرّ التوقيع | 5 دقائق |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | لا شيء | سرّ عميل التطبيق (لا سرّ webhook منفصل) | لا شيء |
يغطّي هؤلاء الأربعة ثلاثة محاور متعامدة. فالسلسلة المُوقَّعة إمّا الجسم وحده وإمّا وصلٌ مع طابع زمني، وحتى الفاصل يختلف: Stripe يستخدم . بينما Slack يستخدم :. والترميز hex عند ثلاثة وbase64 عند واحد. والسرّ يأتي من بيانات اعتماد webhook مخصّصة عند ثلاثة، ومن سرّ عميل التطبيق عند Shopify، وهذه هي التفصيلة التي يخطئ فيها الناس أكثر من غيرها، لأن في واجهة الإدارة حقلاً يحمل تسمية «webhook» وليس هو المطلوب.
ولتجسيد الفروق، هذا جسم واحد موقَّع بأربع طرق بسرّ واحد:
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| الشكل | القيمة |
|---|---|
| نمط GitHub | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| نمط Shopify | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| نمط Stripe | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| نمط Slack | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
اقرأ الصفّين الأولين معاً، فهما البصمة نفسها ذات الـ32 بايتاً مكتوبة مرّتين: أربعة وستون محرفاً بترميز hex، أو أربعة وأربعون محرفاً بترميز base64 مع الحشو. ولا شيء في السلسلتين يشير إلى أنهما متساويتان، ولهذا فالمقارنة عبر ترميزين مختلفين تنتج عدم تطابق يصمد أمام كل فحص من نوع «لكن السرّ صحيح» يمكن أن يخطر لك.
والصفّان الأخيران يبرهنان النصف الآخر من الفكرة. الجسم نفسه، والسرّ نفسه، والخوارزمية نفسها، ولا تشبه أيٌّ من البصمتين بصمةَ GitHub، لأن السلسلة التي تُحسَب تجزئتها تبدأ الآن بطابع زمني. ومعظم البلاغات عن فشل التحقق من توقيع webhook في Stripe تعود إلى هذا الصفّ بالذات: الكود حسب تجزئة الجسم وحده ولم يُقدِّم عليه قيمة t والنقطة. أعد إنتاج الأربعة كلها في مولّد HMAC بتعديل حقل الرسالة وحده وتبديل صيغة المخرَج، فتصير الفروق ملموسة أمامك.
ولعمود الطابع الزمني نتيجة عملية واحدة: بصمة Stripe أو Slack صالحة دقائق معدودة فقط، فلا يمكنك أن تلتقط توقيعاً اليوم وتعيد إرساله في اختبار غداً. أمّا توقيعات GitHub وShopify فلا تنتهي صلاحيتها أبداً، وهذا يجعل تشخيصها أسهل بكثير، ويعني كذلك أن الحماية من إعادة الإرسال مسؤوليتك أنت.
3. مشكلة نص الطلب الخام
إطار العمل لديك أتلف البايتات سلفاً
أُطُر الويب مبنيّة لتعفيك من التحليل. وهذه الراحة نفسها هي ما يكسر التحقّق من التوقيع، لأن البايتات الأصلية تكون قد ذهبت قبل أن يعمل معالِجك.
تقرأ express.json() دفق الطلب وتحلّله وتستبدل req.body بكائن JavaScript. والدفق يُستهلَك ولا يمكن قراءته مرّة ثانية. وفي FastAPI، إعلانُ نموذج Pydantic أو معامل جسم من نوع dict يعني أن إطار العمل يقرأ ويحلّل قبل الدخول إلى دالّتك. وRails يملأ params من جسم JSON عبر وسيط يعمل قبل إجراء المتحكّم لديك. ومحوّل Jackson في Spring يحوّل الجسم إلى صنف DTO لديك، ودفق الإدخال HttpServletRequest الكامن تحته لا يُقرأ افتراضياً إلا مرّة واحدة.
لا شيء هنا علّة. كل واحد من هؤلاء يفعل ما أُعِدّ ليفعله. والمشكلة أن التوقيع يغطّي بايتات، والكائن ليس بايتات، وإعادة تحويل الكائن إلى بايتات عملية مختلفة عن العملية التي نفّذها المزوّد.
لماذا تنجح إعادة التسلسل أحياناً
إعادة التسلسل تغيّر البايتات، وهذا صحيح لكنه ناقص. والنصف الغائب هو ما يجعل تشخيص هذا الفشل عسيراً إلى هذا الحدّ: أحياناً لا تغيّرها على الإطلاق.
وهذه نتيجة JSON.stringify(JSON.parse(body)) === body مقيسةً على أشكال حمولة مختلفة:
| شكل الحمولة | البايتات بعد الذهاب والعودة | التغيّر |
|---|---|---|
{"id":42,"event":"user.created"} | متطابقة | لا تغيّر، ولهذا تجتاز الاختبارات المحلية |
{"amount":1.0} | تغيّرت | → {"amount":1} |
{"n":1e3} | تغيّرت | → {"n":1000} |
{"id":12345678901234567890} | تغيّرت | → {"id":12345678901234567000} (فقدان دقّة) |
{"name":"caf\u00e9"} | تغيّرت | → {"name":"café"} (ستة بايتات تصبح اثنين) |
{"a":1}\n | تغيّرت | ابتُلِع السطر الجديد اللاحق |
{ "a" : 1 } | تغيّرت | ابتُلِعت المسافات الداخلية |
{"v":-0.0} | تغيّرت | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | تغيّرت | → {"p":0.1} |
انظر إلى الصفّ الأول. الكائن المسطّح الذي فيه عدد صحيح وسلسلة ASCII قصيرة يذهب ويعود بايتاً ببايت، فالمُتحقِّق الذي يحلّل ثم يعيد التسلسل يجتاز كل اختبار كتبته على بيانات اختبار من هذا النوع. ثم تنشر، فتفشل أول حمولة تحمل مبلغاً مالياً قيمته 1.0، أو مُعرِّفاً يتجاوز 2^53، أو اسم عميل فيه علامة تشكيل لاتينية. لا تفشل الحمولات كلها، بل هذه الأشكال وحدها.
هذا هو الميكانيزم خلف «يعمل محلياً، و401 متقطّع في الإنتاج»، وهو أسوأ كثيراً من مُتحقِّق يفشل دائماً. فالمُتحقِّق الذي يفشل دائماً يُصلَح في ساعة. أمّا الذي يفشل في 3% من الأحداث فيُلقى اللوم فيه على المزوّد، ويُعاد الطلب، ويُصعَّد، ثم يبقى أسابيع بلا حلّ. فإن كانت نسبة فشلك في مكان ما بين الصفر والمئة بالمئة حصراً، فهذا الجدول هو أول ما تنظر فيه.
ترتيب المفاتيح هو السبب الذي يتوقّعه الناس وأقلّه احتمالاً عملياً، لأن JSON.parse يحفظ ترتيب الإدراج للمفاتيح النصية. الأرقام والمسافات البيضاء هما الجاني الحقيقي.
كيف تحصل على نص الطلب الخام في كل إطار عمل
Express، والمُحلِّل الخاص بالمسار مُسجَّل قبل مُحلِّل JSON العام:
const express = require('express');
const crypto = require('crypto');
const app = express();
// يجب تسجيل هذا المسار قبل app.use(express.json()).
// body-parser يعلّم الطلب بأنه محلَّل، فيعيد raw() اللاحق {} في صمت.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // Buffer، لا كائن
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // احسب تجزئة الـ Buffer مباشرةً، بلا toString()
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // بقية المسارات ما زالت تحصل على JSON محلَّل
app.listen(3000);
وإن لم تستطع إعادة ترتيب الوسائط، فاحتفظ بنسخة أثناء التحليل بدلاً من ذلك:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI. تخزّن Starlette الجسم مؤقتاً، فتعيد await request.body() البايتات الأصلية حتى في معالِج يستقبل كذلك نموذجاً محلَّلاً:
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # بايتات، كما وردت بالضبط
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails، حيث تعطيك request.raw_post الجسم غير المحلَّل سلسلةً نصية:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go، حيث تقرأ الجسم بنفسك وعليك أن تتذكّر أنه يُستنزَف بعد ذلك:
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// فُكَّ التسلسل من raw، لا من r.Body أبداً — لم تبقَ فيه بايتات.
w.WriteHeader(http.StatusOK)
}
Spring، حيث طلب byte[] يتخطّى Jackson كليّاً:
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
وContentCachingRequestWrapper هو البديل حين يجري الفحص في مُرشِّح ولا تستطيع تغيير بصمة المتحكّم. وله فخّ خاصّ به: لا تعيد getContentAsByteArray() بايتات إلا بعد أن يقرأ شيءٌ في المسار التالي الدفقَ، فمناداتها قبل chain.doFilter(...) تعطيك مصفوفة فارغة.
4. عدم تطابق الترميز: hex وbase64 والمفتاح نفسه
ثلاثة قرارات ترميز منفصلة تفصل بين بصمتك وقيمة الترويسة، وأيٌّ منها كافٍ وحده لكسر المقارنة.
أوّلها ترميز البصمة. مخرَج HMAC-SHA256 هو 32 بايتاً. مكتوباً بترميز hex صغير الأحرف يصبح 64 محرفاً؛ ومكتوباً بترميز base64 القياسي يصبح 44 بما فيها حشو =. وهذان الصفّان من القسم 2 موضوعان جنباً إلى جنب:
| الترميز | عدد المحارف | البايتات الـ32 نفسها مكتوبة هكذا |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
وقاعدة سريعة وأنت تحدّق في ترويسة غير مألوفة: إن كانت القيمة 64 محرفاً من 0-9a-f فهي hex. وإن كانت 44 محرفاً تنتهي بـ=، أو تحتوي + أو / أو أحرفاً كبيرة، فهي base64. وحين تريد التأكّد لا التخمين، مرّر قيمة base64 عبر أداة فكّ ترميز Base64 وتحقّق أنها تعطي 32 بايتاً؛ فإن أعطتها، فالسلسلتان تصفان البصمة نفسها وأنت كنت تقارن صيغتين نصيتين لا توقيعين.
وثانيها بادئة القيمة. يرسل GitHub البادئة sha256= أمام قيمة hex. ويرسل Slack البادئة v0=. ويلفّ Stripe كل شيء في قائمة أزواج key=value مفصولة بفواصل. ولا محرف من هذه جزءٌ من البصمة، فإمّا أن تُقشِّر البادئة من الترويسة وإمّا أن تضيفها إلى قيمتك أنت. وترك الأمرين معاً هو أشيع سبب منفرد لأن يُبلِّغ تنفيذٌ صحيح عن عدم تطابق توقيع HMAC، وفي Node لا يُبلِّغ عن عدم تطابق أصلاً، كما يوضّح القسم 7.
وثالثها ترميز المفتاح. السرّ بايتات كذلك، والسلسلة الواحدة مقروءةً بـUTF-8 أو hex أو base64 تعطي ثلاثة مفاتيح مختلفة. والمزوّدون الذين يسلّمونك رمزاً نصياً مثل whsec_... يريدون UTF-8، لكن أنظمة داخلية كثيرة توزّع أسراراً بترميز base64 أو hex يجب فكّ ترميزها قبل التوقيع. وهذا النمط من الفشل مطابق في شكله لنظيره في JWT، وقد تناولناه بتفصيل في خطأ invalid signature في JWT: كل الأسباب وطرق إصلاحها، بما في ذلك كيف تعرف إن كان سرٌّ ما بترميز base64 أم نصّاً صريحاً.
5. الطابع الزمني وهامش التسامح ونوافذ إعادة الإرسال
يمكنك أن تحسب بصمة تتطابق تماماً ومع ذلك تُرفَض. فالمزوّدون الذين يُدرِجون طابعاً زمنياً يتوقّعون منك أن تفحصه، والطابع الزمني القديم توقيعٌ صالح عليك رفضه على أي حال.
| المزوّد | موضع الطابع الزمني | النافذة |
|---|---|---|
| Stripe | t= داخل Stripe-Signature | 5 دقائق (300 ثانية) |
| Slack | ترويسة X-Slack-Request-Timestamp | 5 دقائق |
| GitHub | لا يُرسَل | لا ينطبق |
| Shopify | لا يُرسَل | لا ينطبق |
والخطأ في النافذة مؤذٍ في الاتجاهين. سخاء زائد فيبقى الطلب الملتقَط قابلاً لإعادة الإرسال طول ما تسمح، وهذا يُبطِل معظم الغرض من فحص الطابع الزمني. وتضييق زائد فيبدأ انحراف الساعة العادي برفض تسليمات حقيقية. خمس دقائق هي ما اختاره المزوّدان، وتقليدها آمن.
وقبل أن توسّع هامش التسامح، افحص الساعة. فصور الحاويات لا تشغّل NTP، وآلة افتراضية استُؤنِفت من لقطة قد تتأخّر دقائق عن الزمن الحقيقي دون أن يقول السجلّ عن ذلك شيئاً. والمُضيف الذي ينحرف باطّراد يُنتِج إخفاقات تبدأ عارضة ثم تصبح كاملة، فيُقرأ الأمر كأنه تراجع في الكود ويذهب البحث إلى المكان الخطأ.
وعلّة الساعة الأخرى عدم تطابق في الوحدة. كل مزوّد في الجدول يرسل ثواني الحقبة. قارنها بقيمة بالمللي ثانية مثل Date.now() في JavaScript فيصبح الفرق نحو ألف ضعف العمر الحقيقي، فيخرج كل حدث عن كل نافذة معقولة. والعَرَض هو فحص تسامح يرفض مئة بالمئة من التسليمات بينما البصمة نفسها متطابقة. وإن لم تكن واثقاً بأي وحدة تحمل، فالطول هو الدليل، وثواني الحقبة مقابل المللي ثانية يتناول التحويلات وفخاخ المناطق الزمنية المحيطة بها.
واستخدم سلسلة الطابع الزمني الخام من الترويسة حين تبني السلسلة المُوقَّعة، لا عدداً محلَّلاً ومُعاد التنسيق. فتحليل 1700000000 إلى عدد عشري ثم طبعه من جديد قد يعطي 1700000000.0، وهذا تسلسل بايتات مختلف.
6. السرّ الخاطئ، والأسرار التي تُدوَّر
وقبل أن تتوغّل أكثر في الترميزات، استبعد أبسط الأسباب: قد لا يكون السرّ هو السرّ الصحيح. فوثائق Stripe صريحة في أن «Stripe يولّد مفتاحاً سرّياً فريداً لكل نقطة نهاية»، وأنك إن وجّهت العنوان نفسه إلى مفاتيح الاختبار ومفاتيح الإنتاج معاً فإن «السرّ يختلف لكل واحد منها». ومن هذا تتفرّع ثلاث صور لخطأ واحد.
وضع الاختبار ووضع الإنتاج يحتفظ كلٌّ منهما بسرّ منفصل، فقيمة نسختها ولوحة التحكّم في وضع الاختبار تفشل في كل تسليم إنتاجي. وكل نقطة نهاية تحتفظ بسرّها، وتضيف الوثائق أنه «إن كنت تستخدم نقاط نهاية متعدّدة، فعليك الحصول على سرّ لكل واحدة تريد التحقّق من التوقيعات عليها»: وجّه نقطتَي نهاية إلى معالِج واحد بسرّ واحد في البيئة فيفشل نصف حركة المرور لديك. وstripe listen يطبع سرّ توقيع لإعادة التوجيه المحلية في أداة سطر الأوامر، وهي نقطة نهاية منفصلة عن أي شيء مسجَّل في لوحة التحكّم، فلا يصلح أحدهما بديلاً عن الآخر.
ولا يبدو أي من هذه الحالات علّة ترميز من الخارج. البصمة سليمة الشكل، والمقارنة صحيحة، والقيمة في بيئتك سرّ Stripe حقيقي، لكنه ليس السرّ الذي وقّع هذا التسليم.
والتدوير هو البُعد نفسه يتحرّك من تحتك. وهو أبعد الأسباب شبهاً بمشكلة ترميز، ولهذا يُشخَّص خطأً على أنه علّة في الكود أكثر من غيره. لا شيء في كودك تغيّر، والتحقّق كان يعمل أمس، والآن يفشل جزء من الأحداث.
ونافذة التداخل مقصودة. يُبقي Stripe سرّ نقطة النهاية القديم صالحاً حتى 24 ساعة بعد التدوير، وخلال تلك المدة تحمل ترويسة Stripe-Signature توقيع v1 واحداً لكل سرّ نشط. وShopify يسلك الاتجاه المعاكس: بعد التدوير قد تمضي ساعة كاملة قبل أن يبدأ باستخدام السرّ الجديد في حساب البصمات، فالقديم هو ما تحتاجه في الأثناء.
وسلوك Stripe هو ما يكسر الكود، لأن الترويسة تبدو كأنّ فيها توقيعاً واحداً. فالتقسيم على , وأخذ أول v1 تجده يعمل تماماً إلى أن يصبح هناك اثنان، فتتطابق عندها في نصف الحالات تقريباً بحسب أيّ سرّ وقّع أيّ حدث. مُرّ على جميعها:
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // أبقِ السلسلة الأصلية كما هي
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
وفي هذا الكود تفصيلتان تهمّان إلى جانب الحلقة. الطابع الزمني يدخل الحمولة المُوقَّعة بالسلسلة التي وصل بها، والجسم يُوصَل بوصفه بايتات لا عبر إقحام قالبي، إذ كان الإقحام سيفكّ ترميزه بـUTF-8 أولاً.
والشكل نفسه ينطبق حين تدوّر من جانبك: اقبل السرّ القديم والجديد معاً طول مدة التداخل، ثم أسقط القديم. وأيّ سرّ تنتقل إليه يحتاج عشوائية كاملة، فوَلِّده ولا تكتبه، بأداة مثل مولّد سرّ التوقيع للحصول على قيمة عشوائية من 256 بِتّاً.
7. مقارنة التوقيعات دون تسريب التوقيت
وحين تصبح في يدك بصمتان، تصبح كيفية مقارنتهما قراراً أمنياً. فمساواة السلاسل تعود لحظة تجد بايتاً مختلفاً، فيكشف الزمن الذي تستغرقه عدد البايتات الأولى الصحيحة. والمهاجم القادر على إرسال طلبات كثيرة يستخدم ذلك لاستعادة توقيع صالح بايتاً بايتاً. وهو بطيء وصاخب عبر الإنترنت، وعملي تماماً على شبكة محلية.
كل بيئة تشغيل تأتي بمقارنة ثابتة الزمن:
| اللغة | المقارنة ثابتة الزمن | حين يختلف الطولان |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | تُلقي استثناءً |
| Python | hmac.compare_digest(a, b) | تعيد False |
| Go | hmac.Equal(a, b) | تعيد false |
| PHP | hash_equals($known, $user) | تعيد false |
| Ruby | OpenSSL.secure_compare(a, b) | تعيد false |
وذلك العمود الأخير هو منشأ فئة كاملة من الحوادث المُحيِّرة. Node هو الشاذّ، وهو لا يفشل بأدب:
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
والأطوال تشرح متى يُطلَق هذا. بصمة SHA-256 بترميز hex هي 64 محرفاً. والقيمة في X-Hub-Signature-256 هي 71، لأن sha256= سبعة محارف. انسَ تقشير البادئة فيختلف طول المخزنَين، فتُلقي timingSafeEqual استثناءً بدل أن تعيد false. وغير مُلتقَط، ينتشر ذلك الاستثناء خارج معالِجك فيحوّله Express إلى 500.
والنتيجة أنك تبحث عن استجابة webhook بالرمز 401 غير مصرَّح فتحصل على خطأ خادم، فتذهب لقراءة معالِجك ونداء قاعدة بياناتك ومُوزِّع أحداثك. والعلّة الفعلية سطر واحد فوق المقارنة. ومقارنة بصمة hex من 64 محرفاً ببصمة base64 من 44 محرفاً تُلقي استثناءً للسبب نفسه، أي أن عدم تطابق الترميز في Node يظهر كذلك على هيئة 500 بدل رفض نظيف.
والعلاج أن تفحص الطول بنفسك وتعيد false:
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // حاجز قبل النداء
return crypto.timingSafeEqual(a, b);
}
وتسريب الطول لا ضرر فيه؛ فطول البصمة تحدّده الخوارزمية وهو معلوم للعموم. الذي لا يجوز تسريبه هو أيّ بادئة تطابقت. وتبويب التحقّق في مولّد HMAC يدمج فرق الطول في المُراكِم نفسه الذي تجري فيه المقارنة ثابتة الزمن، بدل أن يعود مبكّراً، فيعود اختلاف الطول قيمةَ false عادية لا استثناءً، وتستطيع فحص قيمة ترويسة مقابل بصمتك المحسوبة دون كتابة كود يُرمى بعد الاستعمال.
8. حين تغيّر طبقة النقل بايتاتك
استبعدت السلسلة المُوقَّعة ونص الطلب الخام والترميزات والساعة والتدوير. والباقي هو احتمال أن البايتات الواصلة إلى عمليتك ليست البايتات التي غادرت المزوّد.
الضغط أوّل المشتبهين. قد يرسل مزوّد أو وسيط الجسم مضغوطاً بـgzip مع Content-Encoding: gzip. والتوقيع يغطّي الحمولة غير المضغوطة، فعليك حساب التجزئة بعد فكّ الضغط. وبعض الأُطُر يفكّ الضغط بشفافية وبعضها يسلّمك البايتات المضغوطة، وجسمٌ يبدو في سجلّك قمامةً ثنائية هو الدليل.
ثم النقل المُقطَّع. مع Transfer-Encoding: chunked لا وجود لـContent-Length، والكود الذي يثق بتلك الترويسة في تحديد حجم مخزن القراءة يقتطع الجسم. وبصمة جسم مقتطع بصمة سليمة الشكل لبايتات خاطئة: لن تتطابق أبداً، ولا يبدو أي شيء معطوباً.
وأي طبقة وسيطة تقرأ الجسم وتعيد كتابته قادرة على تغييره. فـAWS API Gateway قد يرمّز الجسم بـbase64 قبل وصوله إلى Lambda، فعليك فكّ الترميز قبل حساب التجزئة. ومُوازِنات حمل التطبيقات وشِبَاك الخدمات وجدران حماية تطبيقات الويب (WAF) تُوحِّد الحمولات أو تعيد ترميزها كذلك. اختبر ذلك بمقارنة طول البايتات الذي يراه معالِجك بـContent-Length التي أرسلها المزوّد.
وقد تحتوي الحمولات محارف خارج ASCII، ووثائق GitHub صريحة في أن الحمولة يجب أن تُعالَج بترميز UTF-8. وفكّ ترميز الجسم إلى سلسلة نصية بمجموعة محارف خاطئة ثم إعادة ترميزه يدمّر كل محرف متعدّد البايتات. وعلامة ترتيب البايتات لـUTF-8، أي EF BB BF، حين يُقدِّمها محرّر أو مُسلسِل حسن النية، تضيف ثلاثة بايتات لم تُوقَّع قطّ.
وأخيراً نهايات الأسطر. الجسم الذي عبر حدود ملف في النمط النصي قد يصل وLF فيه مُعاد كتابته إلى CRLF. واقرأ كذلك مواصفة المزوّد عن السلسلة المُوقَّعة بالضبط: بعضهم يُلحِق محرفاً من عنده، وTypeform حالة موثَّقة يكون فيها سطر جديد لاحق جزءاً من السلسلة التي تُحسَب تجزئتها. وحين تذكر وثائق مزوّد أي محرف زائد، فخُذها حرفياً.
9. سياق عمل تشخيصي قابل للتكرار
شغّل هذه بالترتيب. كل خطوة إمّا تجد العلّة وإمّا تحذف فرعاً، والتوقّف مبكّراً هو المقصود.
- سجّل البايتات الخام قبل أن يعمل أي وسيط. اكتب الجسم في ملف، أو سجّل طوله بالبايتات مع بصمة SHA-256 له، من أبكر نقطة تصلها في دورة حياة الطلب. الطول وحده يحلّ عدداً مفاجئاً من الحالات: قيمة تزيد بواحد عن المتوقّع هي سطر جديد لاحق، وتزيد بثلاثة هي علامة BOM.
- احسب البصمة يدوياً. الصق تلك البايتات وسرّك في مولّد HMAC، واختر SHA-256، واضبط صيغة المخرَج لتطابق الترويسة. هذه الخطوة تشقّ المشكلة نصفين نظيفين: إمّا المدخلات وإمّا كودك.
- قارن القيمة المحسوبة يدوياً بالترويسة. التساوي يعني أن البايتات والسرّ صحيحان معاً وأن العلّة في مكان ما من مسار كودك، فاذهب لقراءة مقارنتك. وعدم التساوي يعني أن أحد المدخلات خاطئ، فتابع.
- راجع السلسلة المُوقَّعة في جدول القسم 2. هل يُقدِّم هذا المزوّد طابعاً زمنياً؟ وبأي فاصل؟ أضف البادئة في الأداة وأعد الحساب.
- بدّل ترميز البصمة. أعد الحساب بترميز hex ثم بترميز base64 وقارن الاثنين بالترويسة. فقيمة ترويسة من 44 محرفاً تنتهي بـ
=هي base64، أيّاً كان ما افترضه كودك. - بدّل ترميز المفتاح. جرّب السرّ نصّاً، ثم hex، ثم base64. أحد الثلاثة يُنتِج تطابقاً عادةً، وهذا يخبرك بما يتوقّعه المزوّد.
- افحص الساعة وحالة التدوير. قارن وقت خادمك بمصدر معلوم، وتأكّد أنك تتعامل مع ثواني الحقبة، وافحص لوحة تحكّم المزوّد بحثاً عن تدوير في الساعات الـ24 الأخيرة.
وعادتان تجعلان هذه الحلقة أسرع كثيراً. الأولى: التقط حمولة فاشلة واحدة واعمل عليها بلا اتصال بدل انتظار التسليم التالي. والثانية: أعد تشغيل ذلك الجسم الملتقَط على نقطة نهايتك بتوقيع ثابت حتى لا يتغيّر المُدخَل بين محاولة وأخرى. ومنشئ أوامر cURL يجمع الطلب بالترويسات نفسها بالضبط وبجسم يُقرأ من ملف، وهذا يُبقي البايتات مستقرّة بين التشغيلات. وإعادة إنتاج الفشل متى شئت هي ما يحوّل بلاغاً متقطّعاً إلى علّة واحدة محدّدة تستطيع إصلاحها.
وإن احتجت مع ذلك إلى فتح تذكرة دعم، فأدرِج طول الجسم الذي حسبت تجزئته بالبايتات، وقيمة الترويسة حرفياً، وطريقة بناء السلسلة المُوقَّعة التي استخدمتها، وترميز البصمة. ولا تُدرِج السرّ نفسه أبداً.
الأسئلة الشائعة
لماذا يعمل توقيع webhook لديّ محلياً ويفشل في الإنتاج؟
حمولتك الاختبارية تنجو على الأرجح من ذهاب وعودة عبر JSON بلا تغيّر، فتكون إعادة تسلسلها بلا ضرر. أمّا الحمولات الحقيقية فتحتوي أعداداً عشرية وأعداداً صحيحة كبيرة ومحارف هروب Unicode أو مسافات بيضاء زائدة، وهذه تغيّر البايتات فعلاً. وقّع نص الطلب الخام لا نسخة مُعاد تسلسلها؛ والجدول في القسم 3 يبيّن أي الأشكال يكسر.
هل أُدرِج البادئة sha256= عند مقارنة التوقيعات؟
قشِّرها، أو أضفها إلى قيمتك حتى تتطابق السلسلتان تماماً. فبصمتك المحسوبة بترميز hex هي 64 محرفاً وقيمة الترويسة 71 مع البادئة. وبعض دوال المقارنة تعيد false عند اختلاف الطول، أمّا timingSafeEqual في Node فتُلقي استثناءً بدل أن تعيد false.
هل أستطيع التحقّق من التوقيع بعد أن يحلّل إطار العمل JSON؟
ليس بثقة. فإعادة التسلسل تعيد إنتاج البايتات الأصلية فقط للحمولات التي لا أعداد عشرية فيها ولا أعداداً صحيحة تتجاوز 2^53 ولا محارف هروب Unicode ولا مسافات بيضاء زائدة. ولحظةَ يظهر واحد منها تتغيّر البصمة، فيجتاز التحقّق في الاختبار ويفشل في جزء من أحداث الإنتاج.
لماذا يُنتِج Stripe وGitHub توقيعين مختلفين للحمولة نفسها؟
لأنهما يحسبان تجزئة سلسلتين مختلفتين. GitHub يوقّع نص الطلب الخام وحده. وStripe يوقّع الطابع الزمني، ثم نقطة . حرفية، ثم الجسم، فتُنتِج حمولة واحدة سُلِّمت في وقتين مختلفين بصمتين مختلفتين. وSlack يُقدِّم v0: وطابعه الزمني الخاص. الخوارزمية نفسها، والمُدخَل مختلف.
ما ينبغي أن يكون طول هامش الطابع الزمني؟
خمس دقائق هي ما يستخدمه Stripe وSlack، ونسخُها افتراض معقول. فالنوافذ الأقصر ترفض تسليمات مشروعة حين ينحرف وقت خادمك. والأطول توسّع المدة التي يمكن فيها إعادة تشغيل طلب ملتقَط. زامِن الساعات مع NTP قبل تخفيف الهامش.
هل تعيد timingSafeEqual القيمة false حين يختلف الطولان؟
لا. Node يُلقي RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. وغير مُلتقَط، يصبح ذلك 500 بدل 401، فيرسلك لتشخيص معالِجك بدل السطر الذي فوق المقارنة. قارن الطولين أولاً وأعِد false بنفسك.
دوّر المزوّد السرّ، فلماذا ما زالت بعض طلبات webhook تفشل؟
نوافذ التدوير متداخلة. يُبقي Stripe السرّ القديم صالحاً حتى 24 ساعة ويرسل توقيع v1 واحداً لكل سرّ نشط، فيفشل الكود الذي يقرأ أول v1 فقط في نصف الأحداث تقريباً. وShopify قد يستغرق ساعة كاملة قبل أن يبدأ باستخدام السرّ الجديد.
الخاتمة
التحقّق مقارنة بايتات، ولذلك فإن فشل التحقق من توقيع webhook يعود دائماً إلى خلاف حول البايتات لا إلى أي شيء تعميّ. افصل الأبعاد الثلاثة وأنت تُشخِّص:
- أي بايتات وُقِّعت؟ التقط نص الطلب الخام قبل أن يمسّه أي مُحلِّل. ولا تحسب أبداً تجزئة كائن مُعاد تسلسله، فهو يتطابق بما يكفي لاجتياز اختباراتك ولا يكفي للعمل.
- أي بايتات مفتاح استُخدمت؟ قراءة السرّ الواحد نصّاً أو بـhex أو بـbase64 تعطي ثلاثة مفاتيح مختلفة.
- بأي ترميز قارنت؟ hex يعطي 64 محرفاً، وbase64 يعطي 44، وكلاهما يصف البايتات الـ32 نفسها.
- وكيف قارنت؟ ضع حاجزاً على الطول، ثم استخدم دالّة بيئة التشغيل ثابتة الزمن.
- وما بقي بعد ذلك: بادئة الطابع الزمني، وبادئة القيمة، ونافذة التسامح، وتداخل التدوير، وطبقة النقل، بهذا الترتيب تقريباً في الاحتمال.
وحين تريد قيمة تثق بها لتقارن بها، فاحسبها خارج تطبيقك: الصق الجسم والسرّ في مولّد HMAC واتركه يخبرك أيّ الطرفين مخطئ.