İmza doğrulama
Adresinizi bilen herkes ona istek gönderebilir. Bir gönderim, imzası aboneliğinizin imza sırrıyla tuttuğunda Quatlas’tandır.
Yöntem
Bölüm başlığı “Yöntem”Quatlas-Timestamp(Unix saniyesi) veQuatlas-Signaturebaşlıklarını okuyun.- Zaman damgası saatinizden 5 dakikadan uzaksa gönderimi reddedin. Bu, eski bir gönderimin yeniden oynatılmasını önler.
<timestamp>.<ham gövde>metninin HMAC-SHA256 değerini hesaplayın: zaman damgası başlığının değeri, bir nokta, ardından gövdenin aldığınız hâliyle baytları. Anahtar, imza sırrınızın (whsec_...) UTF-8 baytlarıdır. Sonucu küçük harf hex olarak yazın.Quatlas-Signature, virgülle ayrılmış bir ya da daha fazlav1=<hex>değeri taşır. Herhangi birv1=değeri sizinkiyle sabit sürede karşılaştırıldığında eşitse gönderimi kabul edin. Sır yenilemesinin geçiş süresinde, geçerli her sır için bir tane olmak üzere iki değer vardır.Quatlas-Event-Idile tekilleştirin: aynı olay birden fazla gelebilir.
Aşağıdaki her işlev ham gövdeyi, iki başlığı ve sırrınızı alır, gönderimin gerçek olup olmadığını döner. Platformun imzaladığı şekilde imzalanmış gönderimlerle test edilmiştir; yenilemedeki iki imza ve 5 dakikalık pencere dahil.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // refuse deliveries signed more than 5 minutes from your clock
/** * Verifies a Quatlas webhook delivery. * rawBody: the request body as a Buffer, exactly as received (not re-serialised JSON). * headers: the request headers, lower-cased names (as Node gives them). * secret: the subscription's signing secret (whsec_...). */export function verifyQuatlasSignature(rawBody, headers, secret) { const timestamp = headers["quatlas-timestamp"]; const signature = headers["quatlas-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string" || !/^\d+$/.test(timestamp)) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from( crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"), );
// One v1= value per valid secret (two during a secret rotation): accept when any one matches. return signature.split(",").some((part) => { const value = part.trim(); if (!value.startsWith("v1=")) return false; const candidate = Buffer.from(value.slice(3)); return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected); });}Express’te ham gövdeyi yalnız bu rota için alın:
import express from "express";import { verifyQuatlasSignature } from "./verify.mjs";
const app = express();app.post("/quatlas/webhooks", express.raw({ type: "application/json" }), (req, res) => { if (!verifyQuatlasSignature(req.body, req.headers, process.env.QUATLAS_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); // event.id daha önce işlendiyse atlayın; işi kuyruğa koyun, sonra yanıt verin. res.sendStatus(204);});import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300 # refuse deliveries signed more than 5 minutes from your clock
def verify_quatlas_signature(raw_body: bytes, timestamp: str | None, signature: str | None, secret: str) -> bool: """Verifies a Quatlas webhook delivery.
raw_body: the request body as bytes, exactly as received (not re-serialised JSON). timestamp, signature: the Quatlas-Timestamp and Quatlas-Signature headers. secret: the subscription's signing secret (whsec_...). """ if not timestamp or not signature or not (timestamp.isascii() and timestamp.isdigit()): return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False
expected = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
# One v1= value per valid secret (two during a secret rotation): accept when any one matches. for part in signature.split(","): part = part.strip() if part.startswith("v1=") and hmac.compare_digest(part[3:], expected): return True return FalseFlask ile:
import json, osfrom flask import Flask, requestfrom verify import verify_quatlas_signature
app = Flask(__name__)
@app.post("/quatlas/webhooks")def quatlas_webhook(): raw = request.get_data() # ayrıştırmadan önceki ham baytlar if not verify_quatlas_signature(raw, request.headers.get("Quatlas-Timestamp"), request.headers.get("Quatlas-Signature"), os.environ["QUATLAS_WEBHOOK_SECRET"]): return "", 400 event = json.loads(raw) # event["id"] daha önce işlendiyse atlayın; işi kuyruğa koyun, sonra yanıt verin. return "", 204using System;using System.Globalization;using System.Security.Cryptography;using System.Text;
public static class QuatlasWebhook{ // Refuse deliveries signed more than 5 minutes from your clock. private const long ToleranceSeconds = 300;
/// <summary>Verifies a Quatlas webhook delivery.</summary> /// <param name="rawBody">The request body, exactly as received (not re-serialised JSON).</param> /// <param name="timestamp">The Quatlas-Timestamp header.</param> /// <param name="signature">The Quatlas-Signature header.</param> /// <param name="secret">The subscription's signing secret (whsec_...).</param> public static bool Verify(byte[] rawBody, string? timestamp, string? signature, string secret) { if (string.IsNullOrEmpty(signature) || !long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > ToleranceSeconds) return false;
var prefix = Encoding.UTF8.GetBytes(timestamp + "."); var signed = new byte[prefix.Length + rawBody.Length]; prefix.CopyTo(signed, 0); rawBody.CopyTo(signed, prefix.Length); var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hash).ToLowerInvariant());
// One v1= value per valid secret (two during a secret rotation): accept when any one matches. foreach (var part in signature.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { if (!part.StartsWith("v1=", StringComparison.Ordinal)) continue; if (CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(part[3..]), expected)) return true; } return false; }}ASP.NET Core minimal API ile:
app.MapPost("/quatlas/webhooks", async (HttpRequest request, IConfiguration config) =>{ using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); var body = buffer.ToArray();
if (!QuatlasWebhook.Verify(body, request.Headers["Quatlas-Timestamp"], request.Headers["Quatlas-Signature"], config["Quatlas:WebhookSecret"]!)) return Results.BadRequest();
var evt = JsonDocument.Parse(body).RootElement; // evt.GetProperty("id") daha önce işlendiyse atlayın; işi kuyruğa koyun, sonra yanıt verin. return Results.NoContent();});import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class QuatlasWebhook { // Refuse deliveries signed more than 5 minutes from your clock. private static final long TOLERANCE_SECONDS = 300;
private QuatlasWebhook() {}
/** * Verifies a Quatlas webhook delivery (Java 17 or later). * * @param rawBody the request body, exactly as received (not re-serialised JSON) * @param timestamp the Quatlas-Timestamp header * @param signature the Quatlas-Signature header * @param secret the subscription's signing secret (whsec_...) */ public static boolean verify(byte[] rawBody, String timestamp, String signature, String secret) { if (timestamp == null || signature == null || !timestamp.matches("[0-9]{1,18}")) return false; if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) return false;
byte[] expected; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII); } catch (GeneralSecurityException e) { throw new IllegalStateException(e); }
// One v1= value per valid secret (two during a secret rotation): accept when any one matches. for (String part : signature.split(",")) { String value = part.trim(); if (value.startsWith("v1=") && MessageDigest.isEqual(value.substring(3).getBytes(StandardCharsets.US_ASCII), expected)) { return true; } } return false; }}Spring Web ile gövdeyi bayt olarak alın:
@PostMapping("/quatlas/webhooks")public ResponseEntity<Void> quatlasWebhook( @RequestBody byte[] body, @RequestHeader(value = "Quatlas-Timestamp", required = false) String timestamp, @RequestHeader(value = "Quatlas-Signature", required = false) String signature) { if (!QuatlasWebhook.verify(body, timestamp, signature, secret)) { return ResponseEntity.badRequest().build(); } // JSON'u ayrıştırın, olay kimliği daha önce işlendiyse atlayın, işi kuyruğa koyun, sonra yanıt verin. return ResponseEntity.noContent().build();}Örnek hesap
Bölüm başlığı “Örnek hesap”Sır whsec_test, zaman damgası 1791963721 ve gövde {"id":"evt"} ise imzalanan metin şudur:
1791963721.{"id":"evt"}ve başlık şudur:
Quatlas-Signature: v1=878fa619bb541a51b45e860b0d129b198853c2768f6a15d2d020a9f1e5b2b459Kendi kodunuzu denetlemek için OpenSSL ile üretin. (Tam bir denetim, saatiniz bu sabit örneğin 5 dakika yakınında değilse onu zaman damgası yüzünden yine reddeder.)
printf '%s' '1791963721.{"id":"evt"}' | openssl dgst -sha256 -hmac 'whsec_test'# SHA2-256(stdin)= 878fa619bb541a51b45e860b0d129b198853c2768f6a15d2d020a9f1e5b2b459Denetim başarısız olursa
Bölüm başlığı “Denetim başarısız olursa”4xx ile yanıt verin ve gönderimi işlemeyin. Quatlas 2xx dışındaki her yanıtı başarısızlık sayar ve takvimine göre yeniden dener; gerçek gönderimleri sürekli reddeden bir alıcının (örneğin yenilenen sır devreye alınmadıysa) aboneliği art arda 20 başarısız denemeden sonra kapanır. Bkz. Webhook’lar.
Sırayla denetleyin: ham baytları mı imzalıyorsunuz; güncel sırrı mı (yenileme sırasında öncekini mi) kullanıyorsunuz; saatiniz doğru mu (NTP kullanın); küçük harf hex mi karşılaştırıyorsunuz.
