Webhook-Signature-Verifizierung
Verifiziere den `X-Moonborn-Signature`-HMAC, lehne Replays ab und überlebe eine Secret-Rotation.
Jede Webhook-Delivery trägt einen X-Moonborn-Signature-Header mit
dem Format:
X-Moonborn-Signature: t=1747497600,v1=2c4f8a...
t ist der Unix-Timestamp, zu dem die Signatur erstellt wurde; v1
ist das HMAC-SHA256 von {t}.{rawBody}, hex-encodiert, gekeyt mit
dem Signing-Secret.
Verifizieren (Node.js)
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_AGE_SECONDS = 300;
export function verify(
rawBody: string,
header: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2) as [string, string]),
);
const t = Number(parts['t']);
const v1 = parts['v1'];
if (!Number.isFinite(t) || typeof v1 !== 'string') return false;
// Replay-Window-Guard.
if (Math.abs(Date.now() / 1000 - t) > MAX_AGE_SECONDS) return false;
const signed = `${t}.${rawBody}`;
const expected = createHmac('sha256', secret).update(signed).digest('hex');
return timingSafeEqual(
Buffer.from(v1, 'hex'),
Buffer.from(expected, 'hex'),
);
}Hinweis: nutze die rohen Body-Bytes, nicht ein re-serialisiertes JSON-Objekt — die Signatur geht über die exakten Bytes, die Moonborn gesendet hat.
Verifizieren (Python)
import hmac, hashlib, time
MAX_AGE = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
v1 = parts["v1"]
if abs(time.time() - t) > MAX_AGE:
return False
signed = f"{t}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)Secret-Rotation-Grace
Während der Rotation können Deliveries zwei v1=-Entries
tragen (alt + neu). Behandle jeden matchenden Kandidaten als gültig:
const candidates = header.split(',').filter((p) => p.startsWith('v1='));
const ok = candidates.some((c) => {
const v = c.slice(3);
return timingSafeEqual(
Buffer.from(v, 'hex'),
Buffer.from(expected, 'hex'),
);
});Grace-Period-Default: api.webhooks.secret_rotation_grace_minutes = 60.
Warum der Timestamp-Guard
Ohne t-Age-Rejection kann ein Angreifer, der eine gültige Delivery
abfängt, sie für immer replayen. Fünf Minuten ist ein großzügiger
Bound; verenge auf 60s, wenn dein Receiver regional ist.
Tarif
Webhooks sind Team+; Verifikations-Logik ist auf jedem Tier dieselbe.