การตรวจสอบลายเซ็น Webhook
การตรวจสอบลายเซ็น Webhook
Section titled “การตรวจสอบลายเซ็น Webhook”ทุกการส่ง webhook จะถูกเซ็นด้วย HMAC-SHA256 โดยใช้ secret ที่คุณได้รับตอนลงทะเบียน endpoint ตรวจสอบลายเซ็นก่อนเชื่อถือเนื้อหาของ payload เสมอ — ปลายทางที่ไม่ตรวจสอบลายเซ็นจะประมวลผลคำขอปลอมจากใครก็ตามที่พบ URL ของคุณ
Header
Section titled “Header”| Header | เนื้อหา |
|---|---|
Clienta-Event-Id | รหัสเฉพาะของเหตุการณ์นี้ (คงที่ตลอดการลองส่งซ้ำของเหตุการณ์เดียวกัน) |
Clienta-Event-Type | ประเภทเหตุการณ์ เช่น conversation.created |
Clienta-Timestamp | Unix timestamp (วินาที) ตอนที่เซ็นการส่งนี้ |
Clienta-Signature | t={timestamp},v1={hex signature} — ดูรายละเอียดด้านล่าง |
รูปแบบลายเซ็น
Section titled “รูปแบบลายเซ็น”Clienta-Signature: t=1784178600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt— Unix timestamp เดียวกับ headerClienta-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 ของคุณแต่อย่างใด