Skip to content

การตรวจสอบลายเซ็น Webhook

การตรวจสอบลายเซ็น Webhook

Section titled “การตรวจสอบลายเซ็น Webhook”

ทุกการส่ง webhook จะถูกเซ็นด้วย HMAC-SHA256 โดยใช้ secret ที่คุณได้รับตอนลงทะเบียน endpoint ตรวจสอบลายเซ็นก่อนเชื่อถือเนื้อหาของ payload เสมอ — ปลายทางที่ไม่ตรวจสอบลายเซ็นจะประมวลผลคำขอปลอมจากใครก็ตามที่พบ URL ของคุณ

Headerเนื้อหา
Clienta-Event-Idรหัสเฉพาะของเหตุการณ์นี้ (คงที่ตลอดการลองส่งซ้ำของเหตุการณ์เดียวกัน)
Clienta-Event-Typeประเภทเหตุการณ์ เช่น conversation.created
Clienta-TimestampUnix timestamp (วินาที) ตอนที่เซ็นการส่งนี้
Clienta-Signaturet={timestamp},v1={hex signature} — ดูรายละเอียดด้านล่าง
Clienta-Signature: t=1784178600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — Unix timestamp เดียวกับ header Clienta-Timestamp ถูกรวมอยู่ในเนื้อหาที่เซ็นเอง (แบบเดียวกับ Stripe) หมายความว่าลายเซ็นที่ถูกดักจับไปแล้วจะไม่สามารถนำมาเล่นซ้ำภายใต้ timestamp อื่นได้ — จะตรวจสอบผ่านเฉพาะ t ที่ออกให้มาตอนแรกเท่านั้น
  • v1 — ลายเซ็น HMAC-SHA256 ที่เข้ารหัสเป็น hex

ในช่วงที่มีการหมุนเวียน secret ซ้อนทับกัน header จะมีหลายค่า v1= — หนึ่งค่าต่อ secret ที่ยังใช้ได้ในขณะนั้น คั่นด้วยจุลภาค:

Clienta-Signature: t=1784178600,v1=<sig-under-old-secret>,v1=<sig-under-new-secret>

ตัวตรวจสอบของคุณควรยอมรับ payload หาก ลายเซ็นใดๆ ที่ให้มาตรงกับ secret ใดๆ ที่คุณเชื่อถืออยู่ในขณะนั้นสำหรับ endpoint นั้น (ดู การหมุนเวียน Secret ด้านล่าง) — นี่คือสิ่งที่ทำให้คุณสามารถหมุนเวียน secret ที่เก็บไว้เองได้โดยไม่มีช่วงที่ขาดการส่งข้อมูล

สิ่งที่ถูกเซ็น

Section titled “สิ่งที่ถูกเซ็น”

ลายเซ็นคำนวณจาก:

{timestamp}.{raw request body}

โดยใช้ secret ของ endpoint เป็นคีย์ HMAC เซ็น byte ดิบของ request body — ไม่ใช่เวอร์ชันที่ serialize ใหม่จาก JSON ที่ parse แล้ว ซึ่งอาจต่างกันในเรื่อง whitespace/ลำดับ key และจะทำให้ลายเซ็นไม่ตรงกันโดยไม่เกี่ยวกับการปลอมแปลงจริงเลย

การตรวจสอบลายเซ็น

Section titled “การตรวจสอบลายเซ็น”
// Node.js — ตัวตรวจสอบอ้างอิง ปรับการเก็บ/ค้นหา secret ให้เข้ากับระบบของคุณ
const crypto = require('node:crypto');
const REPLAY_TOLERANCE_SECONDS = 5 * 60; // 5 นาที
/**
* @param {string} signatureHeader ค่า header `Clienta-Signature` แบบดิบ
* @param {Buffer} rawBody byte ของ request body ที่ยังไม่ผ่านการ parse
* @param {Buffer[]} secrets secret ทั้งหมดที่คุณยอมรับสำหรับ endpoint นี้ในขณะนี้
* (ปกติมีตัวเดียว มีสองตัวช่วงที่คุณหมุนเวียน secret เอง)
* @returns {boolean}
*/
function verifyClientaWebhookSignature(signatureHeader, rawBody, secrets) {
const parts = signatureHeader.split(',').map((p) => p.trim());
let timestamp;
const providedSignatures = [];
for (const part of parts) {
const [key, value] = part.split('=', 2);
if (key === 't' && value) {
timestamp = Number(value);
} else if (key === 'v1' && value) {
providedSignatures.push(value);
}
}
if (timestamp === undefined || Number.isNaN(timestamp) || providedSignatures.length === 0) {
return false;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > REPLAY_TOLERANCE_SECONDS) {
return false; // เก่าเกินไป (หรือนาฬิกาคลาดเคลื่อน) — ปฏิเสธ ไม่ใช่แค่บันทึกคำเตือน
}
for (const secret of secrets) {
const signedContent = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedContent).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
for (const provided of providedSignatures) {
let providedBuf;
try {
providedBuf = Buffer.from(provided, 'hex');
} catch {
continue;
}
if (providedBuf.length === expectedBuf.length && crypto.timingSafeEqual(providedBuf, expectedBuf)) {
return true;
}
}
}
return false;
}
// การใช้งานใน handler สไตล์ Express — อ่าน body แบบดิบ ไม่ใช่แบบที่ parse เป็น JSON แล้ว:
app.post('/hooks/clienta', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyClientaWebhookSignature(
req.header('Clienta-Signature'),
req.body, // Buffer จาก express.raw()
// secret ที่คุณได้รับถูกเข้ารหัสแบบ base64url อยู่แล้ว — ต้อง decode กลับเป็น byte ดิบก่อนใช้ HMAC
// อย่าแฮช byte utf8 ของสตริงนั้นตรงๆ เพราะจะได้คีย์ที่ต่างออกไป
[Buffer.from(process.env.CLIENTA_WEBHOOK_SECRET, 'base64url')]
);
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... จัดการเหตุการณ์ ...
res.status(200).end();
});

ช่วงเวลายอมรับการเล่นซ้ำ

Section titled “ช่วงเวลายอมรับการเล่นซ้ำ”

ลายเซ็นจะถูกปฏิเสธหาก Clienta-Timestamp ห่างจากเวลาของเซิร์ฟเวอร์คุณเกิน 5 นาที (ไม่ว่าทิศทางใด) รักษาเวลาของเซิร์ฟเวอร์ให้ตรง (ผ่าน NTP) — ความคลาดเคลื่อนของนาฬิกาเป็นสาเหตุที่พบบ่อยที่สุดของการตรวจสอบลายเซ็นที่ผิดพลาดแบบ false negative

การหมุนเวียน Secret

Section titled “การหมุนเวียน Secret”

การหมุนเวียน secret ของ webhook ทำได้โดยลงทะเบียน endpoint ใหม่และเลิกใช้ endpoint เก่า — ไม่มีการสลับ secret ในที่เดิมสำหรับ endpoint เดียว ในช่วงที่ทับซ้อนกันระหว่างการลงทะเบียน endpoint ใหม่และการลบ endpoint เก่า ทั้งสอง endpoint จะถูกเซ็นและส่งข้อมูลแยกจากกันอย่างอิสระ จึงไม่มีช่วงที่เหตุการณ์ไม่ถูกเซ็นหรือถูกทิ้งไป

ภายในระบบ กุญแจเข้ารหัสแบบ envelope ของ Clienta.ai เอง (ที่ใช้เข้ารหัส secret สำหรับเซ็นที่จัดเก็บไว้) จะหมุนเวียนตามตารางเวลาแยกจาก secret ของ endpoint คุณ และไม่ต้องดำเนินการใดๆ จากฝั่งคุณ — ไม่ส่งผลต่อการตรวจสอบ Clienta-Signature ของคุณแต่อย่างใด