HookGet English להתחיל בחינם

אימות חתימות של וובהוקים

שלוש הכותרות, פונקציית אימות, והטעות שכולם עושים פעם אחת.

בקצרה

  • שלוש כותרות מגיעות עם כל מסירה: מזהה, חותמת זמן וחתימה.
  • ה-HMAC מכסה את המזהה, את חותמת הזמן ואת הגוף המקורי. מאמתים לפני שמפענחים.
  • חלון הזמן הוא מה שהופך בקשה שנלכדה לחסרת ערך מאוחר יותר.
  • בזמן החלפת סוד נשלחות שתי חתימות, כך שהמעבר לא עולה אף מסירה.
POST /hooks HTTP/1.1
webhook-id: msg_01J8ZQ3F9VBAQ4E1S0TZY6P8YV זהה בכל ניסיון חוזר. השתמשו בו כמפתח לסינון כפילויות
webhook-timestamp: 1755264000 מחוץ לחלון של 5 דקות, דחו את הבקשה. זו ההגנה מפני שליחה חוזרת זדונית
webhook-signature: v1,ZeHt1v… v1a,mK4p… HMAC ו-ed25519 זה לצד זה בזמן מעבר בין שיטות, מופרדים ברווח
{"type":"order.created","timestamp":"…","data":{…}} התוכן החתום הוא id.timestamp.body : הבייטים המדויקים, אף פעם לא סריאליזציה מחדש
מסירה אחת כפי שהיא עוברת ברשת. שלוש כותרות, והסיבה שכל אחת מהן קיימת.

היעד שלכם הוא כתובת ציבורית, וכל אחד באינטרנט יכול לשלוח אליה JSON שנראה בדיוק כמו אירוע אמיתי. בלי חתימה, הצד המקבל פשוט מאמין למה שהגיע. החתימה הופכת את השאלה "האם זה אמיתי?" לחישוב שאפשר לבדוק: רק מי שמחזיק בסוד החתימה יכול להפיק את הערך הנכון.

הכותרות

webhook-id:         msg_01M03VKMCJ5H7C1E4K1EEA987B
webhook-timestamp:  1786836013
webhook-signature:  v1,atTVDyPi26ackTT4nZbdk…

HookGet חותם לפי Standard Webhooks, המפרט הפתוח, ולכן צרכן שנכתב לכל שולח תואם לא צריך שום שינוי. webhook-id הוא מזהה המסירה, webhook-timestamp הוא זמן השליחה בשניות, ו-webhook-signature נושא את החתימה עצמה, עם קידומת גרסה.

פונקציית האימות

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const ts = Number(headers['webhook-timestamp']);

  // Outside the window this is a replay, not a delivery.
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${ts}.${rawBody}`)   // the RAW bytes, never a re-serialised object
    .digest('base64');

  return String(headers['webhook-signature'] ?? '')
    .split(' ')
    .some((part) => {
      const [version, value] = part.split(',');
      const a = Buffer.from(value ?? '', 'utf8');
      const b = Buffer.from(expected, 'utf8');
      return version === 'v1' && a.length === b.length && timingSafeEqual(a, b);
    });
}

הפונקציה עושה שלושה דברים בסדר קבוע. קודם היא בודקת שחותמת הזמן נמצאת בתוך חלון של 300 שניות, כי אין טעם לחשב HMAC על בקשה שכבר ישנה מדי. אחר כך היא מחשבת את החתימה הצפויה על המזהה, חותמת הזמן והגוף המקורי. בסוף היא משווה אותה לכל חתימה שבכותרת, בזמן קבוע, ומקבלת את המסירה אם אחת מהן מתאימה.

הטעות שכולם עושים פעם אחת. מי שמפענח את ה-JSON ומרכיב אותו מחדש לפני האימות משנה רווחים וסדר מפתחות, וה-HMAC נכשל. בדרך כלל האשם הוא framework שמפענח את הגוף בשבילכם, מתוך כוונה טובה. שמרו קודם את הבייטים המקוריים.

בשביל מה כל חלק

חלקממה הוא מגן
הסוד המשותףמכל מי ששולח JSON ליעד הציבורי שלכם ומצפה שיאמינו לו
הגוף המקורי בתוך ה-HMACמשינוי התוכן בדרך או בידי גורם ביניים
חותמת הזמן בתוך ה-HMACמשליחה חוזרת זדונית של בקשה שנלכדה, שעות או ימים אחר כך
השוואה בזמן קבועמניחוש החתימה הנכונה בייט אחר בייט לפי זמני התגובה
המזהה הקבועמטיפול כפול כשאותה מסירה נשלחת בניסיון חוזר

יש כאן שני דברים עם שם דומה, וכדאי לא לבלבל ביניהם. מתקפת שליחה חוזרת היא מישהו ששולח שוב בקשה אמיתית שהוא לכד, כדי שתטופל פעם נוספת. שליחה חוזרת (replay) של אירוע ב-HookGet היא פעולה מכוונת שלכם, והיא יוצאת עם חותמת זמן חדשה וחתימה חדשה, ולכן עוברת את האימות. חותמת הזמן חתומה, אז תוקף לא יכול לרענן אותה בלי הסוד.

החלפת סוד בלי לאבד מסירות

ההחלפה מחזירה סוד חדש ומשאירה את הישן בתוקף לאורך חלון חפיפה. בזמן החפיפה שתי החתימות נשלחות באותה כותרת, כך שהצרכן יכול לעבור לסוד החדש מתי שהוא מוכן. לא צריך לתאם פריסה לדקה מסוימת, ואף מסירה לא נדחית רק כי צד אחד התעדכן לפני השני.

curl -X POST https://api.hookget.com/v1/endpoints/ep_…/rotate-secret \
  -H "authorization: Bearer $HOOKGET_KEY" -d '{"expiry_hours":24}'

שאלות

למה החתימה חייבת להיות על הבייטים המקוריים של הגוף?

כי ה-HMAC מחושב על רצף בייטים מדויק. JSON שפוענח והורכב מחדש יכול להכיל בדיוק את אותו תוכן ועדיין להיות שונה ברווח אחד או בסדר של שני מפתחות, וזה מספיק כדי שהחתימה לא תתאים. לכן שומרים את הגוף כפי שהגיע, מאמתים עליו, ורק אחר כך מפענחים.

למה משווים בזמן קבוע ולא עם ===?

השוואה רגילה עוצרת בתו הראשון שלא מתאים, ולכן חתימה שהתחלתה נכונה נבדקת קצת יותר זמן. תוקף שמודד את ההבדל הזה על הרבה בקשות יכול לנחש את החתימה הנכונה בייט אחר בייט. timingSafeEqual לוקחת אותו זמן בכל מקרה, ולכן אין מה למדוד.

מה ההבדל בין מתקפת שליחה חוזרת לבין שליחה חוזרת של אירוע?

שליחה חוזרת (replay) של אירוע היא פעולה שאתם יוזמים, עם חותמת זמן חדשה וחתימה חדשה. מתקפת שליחה חוזרת היא מישהו אחר ששולח שוב בקשה ישנה שהוא לכד. חלון הזמן בפונקציית האימות חוסם את השנייה ולא מפריע לראשונה.

האם קוד אימות שנכתב לספק אחר יעבוד עם HookGet?

אם הספק ההוא חותם לפי Standard Webhooks, כן. HookGet משתמש באותו מפרט פתוח, עם אותן שלוש כותרות ואותו מבנה חתימה, ולכן צרכן שנכתב לכל שולח תואם לא צריך שינוי.

מה קורה לחתימה בזמן החלפת סוד?

במהלך חלון החפיפה נשלחות שתי חתימות באותה כותרת webhook-signature, אחת בכל סוד, מופרדות ברווח. הפונקציה בדף הזה מקבלת מסירה אם אחת מהן מתאימה, ולכן היא עובדת לפני ההחלפה, במהלכה ואחריה.

להתחיל למסור וובהוקים היום

מפנים את הוובהוקים ל-HookGet ורואים את המסירה הראשונה מגיעה, חתומה, תוך פחות מדקה.

פתיחת חשבון חינם לנסות את בודק הוובהוקים החינמי

10,000 מסירות בחודש בחינם, בלי כרטיס אשראי. המסלול החינמי חוסם ולא מחייב, כך שניסיון לא יכול להסתיים בחשבונית.