Weryfikacja podpisów
BeepClock podpisuje każde żądanie webhooka sekretem podpisu webhooka. Zweryfikuj podpis, zanim zaufasz treści: potwierdza on, że żądanie pochodzi z BeepClock i że ciało nie zostało zmienione po drodze.
Nagłówek podpisu
X-BeepClock-Signature: t=1791788292,v1=9f2b...a1,v1=4c8e...77
tto moment podpisania żądania jako znacznik czasu Unix w sekundach (UTC).v1to podpis w wersji 1. Podczas rotacji sekretu nagłówek może zawierać więcej niż jedną wartośćv1.
Jak obliczany jest podpis
Podpis to HMAC-SHA256 ze znacznika czasu, kropki i surowego ciała żądania:
signed_payload = "{t}" + "." + raw_body
signature = lowercase_hex(HMAC_SHA256(key = secret, message = signed_payload))
tto dokładnie ta sama wartość co w nagłówku.raw_bodyto surowe ciało żądania: bajty odebrane z sieci, przed jakimkolwiek parsowaniem JSON.secretto cały sekret podpisu razem z prefiksemwhsec_, zakodowany w UTF-8. Nie usuwaj prefiksu i nie dekoduj sekretu z Base64.- Wynik jest zapisany szesnastkowo, małymi literami, i ma 64 znaki.
Każda próba jest podpisywana w chwili wysłania, dlatego t i podpis różnią się między próbami tego samego dostarczenia, a treść pozostaje taka sama.
Podpis obliczony na payloadzie po ponownej serializacji, np. po JSON.parse i JSON.stringify, nigdy nie będzie zgodny. Odczytaj ciało jako bajty, zweryfikuj podpis i dopiero potem parsuj JSON.
Kroki weryfikacji
- Odczytaj
toraz wszystkie wartościv1z nagłówka. - Oblicz HMAC-SHA256 z
{t}.{raw_body}dla każdego sekretu, który aktualnie akceptujesz. - Porównaj wynik z każdą wartością
v1, używając porównania w czasie stałym. Zgodność z dowolną wartościąv1oznacza poprawny podpis. - Odrzucaj żądania bez poprawnego podpisu, np. kodem
401. - Odrzucaj żądania, w których
tróżni się od bieżącego czasu o więcej niż przyjęta tolerancja, np. 5 minut. Chroni to przed ponownym odtworzeniem przechwyconych żądań. BeepClock sam nie narzuca tolerancji, więc dbaj o synchronizację zegara serwera (NTP).
Przykłady kodu
- Node.js (Express)
- C# (ASP.NET Core)
const crypto = require("node:crypto");
const express = require("express");
const secrets = [process.env.BEEPCLOCK_WEBHOOK_SECRET];
const toleranceSeconds = 300;
function verifySignature(header, rawBody, secrets) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const index = part.indexOf("=");
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === "t") timestamp = value;
if (key === "v1") signatures.push(Buffer.from(value, "hex"));
}
if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
return secrets.some((secret) => {
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest();
return signatures.some(
(signature) => signature.length === expected.length && crypto.timingSafeEqual(signature, expected),
);
});
}
const app = express();
app.post("/beepclock/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.get("X-BeepClock-Signature"), req.body, secrets)) {
return res.sendStatus(401);
}
const deliveryId = req.get("X-BeepClock-Delivery");
const event = JSON.parse(req.body.toString("utf8"));
enqueue(deliveryId, event);
res.sendStatus(204);
});
app.listen(3000);
express.raw zachowuje ciało jako Buffer. Nie używaj express.json() na tej ścieżce: podpis musi być obliczany na oryginalnych bajtach. enqueue oznacza Twój kod, który zapisuje zdarzenie, pomija już przetworzone identyfikatory dostarczeń i obsługuje zdarzenie w tle.
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secrets = new[] { builder.Configuration["BeepClock:WebhookSecret"]! };
app.MapPost("/beepclock/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
if (!BeepClockSignature.IsValid(request.Headers["X-BeepClock-Signature"].ToString(), body, secrets,
DateTimeOffset.UtcNow))
return Results.Unauthorized();
var deliveryId = request.Headers["X-BeepClock-Delivery"].ToString();
var payload = System.Text.Json.JsonDocument.Parse(body);
// Store the event, skip delivery IDs that were already processed and handle it in the background.
return Results.NoContent();
});
app.Run();
static class BeepClockSignature
{
private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
public static bool IsValid(string header, byte[] body, IEnumerable<string> secrets, DateTimeOffset now)
{
long? timestamp = null;
var signatures = new List<byte[]>();
foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))
{
var index = part.IndexOf('=');
if (index <= 0) continue;
var key = part[..index];
var value = part[(index + 1)..];
if (key == "t" && long.TryParse(value, out var t))
timestamp = t;
else if (key == "v1" && value.Length == 64 && value.All(Uri.IsHexDigit))
signatures.Add(Convert.FromHexString(value));
}
if (timestamp is not { } seconds || signatures.Count == 0) return false;
if ((now - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance) return false;
var signedPayload = Encoding.UTF8.GetBytes($"{seconds}.").Concat(body).ToArray();
return secrets.Any(secret =>
{
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signedPayload);
return signatures.Any(signature => CryptographicOperations.FixedTimeEquals(signature, expected));
});
}
}
Wartości testowe
Użyj tych wartości, aby sprawdzić swoją implementację. Na czas tego sprawdzenia wyłącz tolerancję znacznika czasu, ponieważ t ma stałą wartość.
| Dane | Wartość |
|---|---|
| Sekret | whsec_test-secret |
t | 1791788292 |
| Surowe ciało | {"id":"0b9e6c41-5d2a-4f78-93e1-c6a8b2f4d071","event":"webhook.test"} |
Oczekiwane v1 | 81e8ba359713e992413513ead157645df8e568201531f6ef5b3c1764fc5658bc |
Najszybszym sprawdzeniem całej ścieżki jest Wyślij zdarzenie testowe na stronie webhooka: poprawnie działający odbiorca odpowiada kodem 2xx, a błąd podpisu pojawi się jako Twój kod 401.
Rotacja sekretu
Wygeneruj nowy sekret podpisu tworzy nowy sekret i wyświetla go jednorazowo. Przez kolejne 24 godziny każde żądanie jest podpisywane oboma sekretami, więc nagłówek zawiera dwie wartości v1: pierwszą dla nowego sekretu, drugą dla poprzedniego. Po 24 godzinach używany jest tylko nowy sekret.
Aby przeprowadzić rotację bez przerwy w działaniu:
- Wybierz Wygeneruj nowy sekret podpisu i skopiuj nowy sekret.
- Dodaj go w odbiorcy obok starego. Powyższe przykłady kodu akceptują listę sekretów.
- Usuń stary sekret, gdy nie jest już potrzebny, najpóźniej po 24 godzinach.
Generuj nowy sekret zawsze, gdy stary mógł zostać ujawniony, np. po wklejeniu go do zgłoszenia lub na czat albo gdy z zespołu odchodzi osoba, która go znała.