Webhook-ийн гарын үсэг шалгах¶
Таны endpoint-д ирсэн хүсэлт үнэхээр Gerege-ээс ирсэн, body нь замдаа өөрчлөгдөөгүй, хуучин хүсэлтийг дахин тоглуулаагүй гэдгийг нотолно. SDK-тай бол нэг мөр; SDK-гүй бол доорх 25 мөрийн функцийн аль нэгийг хуулна.
- Хугацаа: 15 минут (тест вектороор шалгасан нь).
- Хэрэгтэй: webhook secret (
whsec_…, Тойм), endpoint-ийн түүхий (raw) body-г авах боломж.
Гарын үсэггүй, шалгаагүй webhook-оор хэзээ ч бараа олгохгүй
Хэн ч таны URL руу JSON илгээж чадна. Гарын үсэг таарахгүй, эсвэл толгой алга бол 400 буцаагаад юу ч бүү хий.
1. Толгойнууд¶
| Толгой | Утга |
|---|---|
Gerege-Signature |
t=<unix секунд>,v1=<hex>[,v1=<hex>] |
OpenPay-Signature |
Gerege-Signature-тэй яг ижил утга (OMAS стандартын нэр) |
Gerege-Event-Id · OpenPay-Event-Id |
Event-ийн id (evt_…), body-гийн id-тэй ижил |
Content-Type |
application/json |
User-Agent |
GeregeApp-Webhooks/1 |
Gerege-Signature-ийг уншиж, байхгүй бол OpenPay-Signature-ийг ав. Жишээ:
Gerege-Signature: t=1792659600,v1=01ecc504ae234412067d656d62f44e6358f4d0871b7dc6c63a66885fc25b5f50,v1=e4a4a3fe050cfd0b6a23062c60cd28882e497aebe6639600345e55adbaf0cc57
t— энэ оролдлогыг илгээсэн агшин. Retry бүр шинэt-тэй, шинээр гарын үсэг зурагдана; body нь хэзээ ч өөрчлөгдөхгүй.v1— тухайн endpoint-ийн хүчинтэй secret тус бүрд нэг. Ердийн үед нэг; secret солисноос хойш 24 цагт хоёр (эхэнд шинэ, дараа нь хуучин). Endpoint бүр өөрийн secret-тэй — нэг event хоёр endpoint руу очвол гарын үсэг нь өөр (Тойм).- Мэдэхгүй түлхүүрийг (
v0,v2, …) үл тоомсорло; таслалын хойтох зайг тэвчинэ.
2. Алгоритм¶
signed_payload = t + "." + raw_body
expected = lowercase_hex( HMAC-SHA256( key = webhook_secret, message = signed_payload ) )
- Толгойноос
tба бүхv1-ийг ав.tалга эсвэл тоо биш, эсвэлv1нэг ч алга бол татгалз. |now − t| ≤ 300секунд (хоёр чиглэлд, хил оролцоно) биш бол татгалз — replay хамгаалалт. Серверийн цагаа NTP-ээр тааруул.- Таны хүлээн авах secret тус бүрээр
expected-ийг бод. Түлхүүр ньwhsec_угтвартай бүтэн мөр, UTF-8 byte (base64 decode хийхгүй). - Аль нэг
v1аль нэгexpected-тэй constant-time харьцуулалтаар тэнцвэл хүлээн ав.==,strcmpбүү хэрэглэ. - Дараа нь л body-г JSON болгож parse хий,
id-аар давхардлыг арилга (Idempotent handler).
Нарийн зүйлс:
- Түүхий body. Framework-ийн parse хийсэн объектыг дахин
JSON.stringify/json.dumpsхийвэл гарын үсэг таарахгүй: Gerege-ийн илгээгч<,>,&-ийг\u003c,\u003e,\u0026гэж бичдэг, түлхүүрийн дараалал, зай таных биш. Body-г byte-аар нь ав. - Жижиг үсэгтэй hex. Платформ үргэлж жижиг үсгээр бичнэ; лавлах шалгагч том үсэгтэй
v1-ийг хүлээн авахгүй — та ч бас. - Хүлээн авах secret-ийн жагсаалт: ердийн үед
[одоогийн], солих үед[шинэ, хуучин].
3. Код¶
SDK-тай:
import express from "express";
import { verifyWebhook, signatureFromHeaders, GeregeWebhookError } from "@gerege/miniapp-node";
const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(","); // солих үед "шинэ,хуучин"
app.post("/webhooks/gerege", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = verifyWebhook(req.body, signatureFromHeaders(req.headers), secrets);
queue.add(event); // id-аар dedupe, дараа нь ажил
res.sendStatus(204);
} catch (err) {
if (err instanceof GeregeWebhookError) return res.sendStatus(400); // err.reason
throw err;
}
});
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"
secrets := strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",")
http.HandleFunc("POST /webhooks/gerege", func(w http.ResponseWriter, r *http.Request) {
ev, _, err := miniapp.VerifyRequest(r, secrets, time.Now())
if err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
enqueue(ev) // ev.ID-аар dedupe
w.WriteHeader(http.StatusNoContent)
})
SDK-гүй — доорх функц бүр sdk/testdata/vectors.json-ийн webhook.verification-ийн 20 тохиолдлыг бүгдийг зөв
шийддэг (Node, Go, Python-ийг ажиллуулж шалгасан; PHP-ийг ижил логикоор):
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: Buffer (express.raw), header: Gerege-Signature, secrets: string[]
export function verifyGerege(rawBody, header, secrets, now = Math.floor(Date.now() / 1000), tolerance = 300) {
let t = -1;
const sigs = [];
for (const part of String(header ?? "").split(",")) {
const p = part.trim();
const i = p.indexOf("=");
const key = i < 0 ? p : p.slice(0, i);
const value = i < 0 ? "" : p.slice(i + 1);
if (key === "t" && /^[0-9]+$/.test(value)) t = Number(value);
else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) sigs.push(Buffer.from(value, "hex"));
}
if (t < 0 || sigs.length === 0) return false;
if (Math.abs(now - t) > tolerance) return false;
let ok = false;
for (const secret of secrets) {
const want = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
for (const sig of sigs) ok = timingSafeEqual(sig, want) || ok; // эрт гарахгүй
}
return ok;
}
func Verify(body []byte, header string, secrets []string, now time.Time) bool {
t := int64(-1)
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
t = n
}
case "v1":
sigs = append(sigs, v)
}
}
if t < 0 || len(sigs) == 0 {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
return false
}
ok := false
for _, s := range secrets {
m := hmac.New(sha256.New, []byte(s))
fmt.Fprintf(m, "%d.", t)
m.Write(body)
want := []byte(hex.EncodeToString(m.Sum(nil)))
for _, sig := range sigs {
ok = hmac.Equal([]byte(sig), want) || ok
}
}
return ok
}
import hashlib, hmac, re, time
def verify(raw_body: bytes, header: str, secrets: list[str], now: int | None = None, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"[0-9]+", value):
t = int(value)
elif key == "v1":
sigs.append(value.encode())
if t is None or not sigs:
return False
now = int(time.time()) if now is None else now
if abs(now - t) > tolerance:
return False
ok = False
for secret in secrets:
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest().encode()
for sig in sigs:
ok = hmac.compare_digest(sig, want) or ok
return ok
# Flask: verify(request.get_data(), request.headers.get("Gerege-Signature", ""), secrets)
# Django: verify(request.body, request.headers.get("Gerege-Signature", ""), secrets)
<?php
function gerege_verify(string $rawBody, string $header, array $secrets, ?int $now = null, int $tolerance = 300): bool
{
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$t = (int) $value;
} elseif ($key === 'v1') {
$sigs[] = $value;
}
}
if ($t === null || !$sigs) {
return false;
}
$now ??= time();
if (abs($now - $t) > $tolerance) {
return false;
}
$ok = false;
foreach ($secrets as $secret) {
$want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // жижиг үсэгтэй hex
foreach ($sigs as $sig) {
$ok = hash_equals($want, $sig) || $ok;
}
}
return $ok;
}
$raw = file_get_contents('php://input'); // $_POST биш — түүхий body
$header = $_SERVER['HTTP_GEREGE_SIGNATURE'] ?? ($_SERVER['HTTP_OPENPAY_SIGNATURE'] ?? '');
if (!gerege_verify($raw, $header, explode(',', getenv('GEREGE_WEBHOOK_SECRETS')))) {
http_response_code(400);
exit;
}
Java, C# — Бусад хэл.
4. Ажилласан жишээ¶
sdk/testdata/vectors.json-ийн эхний вектор (payment_intent.succeeded, one secret). Тестийн secret — бодит биш:
| Утга | |
|---|---|
| secret | whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE |
t |
1792659600 (2026-10-22T09:00:00Z) |
signed_payload |
1792659600. + body (яг файлд байгаа byte, \u003croaming \u0026 data\u003e-тэй) |
v1 |
aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821 |
| толгой | t=1792659600,v1=aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821 |
Shell-ээр шалгах (body.json нь вектор дахь body мөр, төгсгөлийн шинэ мөргүй):
printf '%s' "1792659600.$(cat body.json)" \
| openssl dgst -sha256 -hmac 'whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE' -hex
# … aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821
5. Тест векторууд¶
vectors.json нь платформын өөрийн гарын үсэг зурагч кодоос үүсгэгддэг (гараар засахгүй), SDK-уудтай хамт
нийтлэгдэнэ. Хоёр хэсэгтэй:
webhook.signing[]— event бүрийн бодит body, secret(ууд),t,signed_payload, хүлээгдэхsignaturesбаheader. Өөрийн гарын үсэг зурах функцийг (тестдээ webhook дуурайхад) шалгана.webhook.verification[]—{name, secrets, header, body, now, valid}: 20 тохиолдол — цонхны хил (±300 с яг хүчинтэй, ±301 хүчингүй), өөрчлөгдсөн body, дахин encode хийсэн body, буруу secret, солих үеийн хоёрv1(зөвхөн хуучин эсвэл зөвхөн шинэ secret-тэй хүлээн авагч), таслалын хойтох зай, мэдэхгүй түлхүүр, том үсэгтэй hex (хүчингүй),v1-гүй,t-гүй, тоон бусt, өөрt-ийн гарын үсэг, хоосон толгой.
Өөрийн хэрэгжүүлэлтээ бүгдээр нь ажиллуул:
import json
cases = json.load(open("vectors.json"))["webhook"]["verification"]
for c in cases:
got = verify(c["body"].encode(), c["header"], c["secrets"], c["now"])
assert got == c["valid"], c["name"]
Файл одоогоор SDK-ийн хамт түгээгддэг (SDK нийтлэгдэх хүртэл хаалттай) — хэрэгтэй бол Тусламж-аас авна; эсвэл дээрх ажилласан жишээгээр эхэл.