Verifikasi Tanda Tangan
Siapa pun yang tahu URL Anda bisa mengirim permintaan palsu. Karena itu selalu verifikasi tanda tangan sebelum memercayai sebuah event.
Format header
X-Etalasse-Signature: t=1760151845,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tadalah waktu kirim dalam detik Unix.v1adalah tanda tangan heksadesimal. Saat rotasi secret sedang masa tumpang tindih, ada dua nilaiv1di header yang sama.- Setiap percobaan ulang ditandatangani ulang dengan
tyang baru.
Cara menghitung
- Ambil
tdan semuav1dari header. - Susun teks
t+.+ body mentah (persis byte yang diterima). - Hitung
HMAC_SHA256(secret, teks)dalam heksadesimal. Secret adalah seluruh stringwhsec_...sebagai UTF-8. - Bandingkan dengan setiap
v1memakai perbandingan waktu konstan. Cukup satu yang cocok. - Tolak jika selisih
|waktu sekarang - t|lebih dari 300 detik (5 menit), untuk mencegah pemutaran ulang (replay).
Contoh Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.ETALASSE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
if (!header) return false;
const parts = header.split(',').map((p) => p.trim());
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig, 'utf8');
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
// Important: read the RAW body, not parsed JSON.
app.post('/hooks/etalasse', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.get('X-Etalasse-Signature'))) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody);
// TODO: skip if event.id was already processed, then handle the event.
res.status(200).send('ok');
});
app.listen(3000);
Contoh PHP
<?php
$secret = getenv('ETALASSE_WEBHOOK_SECRET'); // whsec_...
$tolerance = 300;
$rawBody = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_X_ETALASSE_SIGNATURE'] ?? '';
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$part = trim($part);
if (strpos($part, 't=') === 0) {
$t = substr($part, 2);
} elseif (strpos($part, 'v1=') === 0) {
$signatures[] = substr($part, 3);
}
}
$valid = false;
if ($t !== null && count($signatures) > 0 && abs(time() - (int) $t) <= $tolerance) {
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) {
$valid = true;
break;
}
}
}
if (!$valid) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
// TODO: skip if $event['id'] was already processed, then handle the event.
http_response_code(200);
echo 'ok';
Kesalahan yang sering terjadi
- Memakai JSON yang sudah di-parse lalu diserialisasi ulang. Spasi, urutan kunci, atau escape berubah sehingga tanda tangan tidak cocok. Pakai body mentah.
- Middleware yang mengubah body (misalnya
express.json()sebelum rute Anda). Pasangexpress.rawpada rute webhook. - Perbandingan biasa (
==). Gunakancrypto.timingSafeEqualatauhash_equals. - Lupa memeriksa
t. Tanpa batas 5 menit, permintaan lama bisa diputar ulang. - Hanya membaca satu
v1. Saat rotasi, tanda tangan yang cocok bisa nilai kedua. Periksa semuanya. - Secret salah atau terpotong. Pakai seluruh string
whsec_..., bukan hanya bagian sesudah awalan, dan hati-hati dengan spasi atau baris baru di variabel lingkungan. - Jam server melenceng. Sinkronkan dengan NTP; jika tidak, semua permintaan sah bisa ditolak.
- Membalas lambat. Balas 2xx secepatnya (batas 8 detik) lalu kerjakan prosesnya di latar belakang.