Webhook signing
Каждое webhook delivery подписано HMAC-SHA256. Бот должен verify подпись перед обработкой — иначе атакующий может slать fake events.
Header
X-Reasonspace-Signature: t=1748340000,v1=4f3e9c8d2a1b5e7f...
t— Unix timestamp (секунды) deliveryv1— hex HMAC-SHA256 отt + "." + raw_body, ключ =WEBHOOK_SECRET
Verify
import hmac
import hashlib
def verify(body: bytes, header: str, secret: str) -> bool:
if not header:
return False
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts["t"], parts["v1"]
except (KeyError, ValueError):
return False
msg = f"{ts}.{body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
const crypto = require('crypto')
function verify(body, header, secret) {
if (!header) return false
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=', 2))
)
const { t, v1 } = parts
if (!t || !v1) return false
const msg = `${t}.${body}`
const expected = crypto
.createHmac('sha256', secret)
.update(msg)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(v1)
)
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verify(body []byte, header, secret string) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
p := strings.SplitN(kv, "=", 2)
if len(p) == 2 {
parts[p[0]] = p[1]
}
}
t, sig := parts["t"], parts["v1"]
if t == "" || sig == "" {
return false
}
msg := t + "." + string(body)
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(msg))
expected := hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
В reasonspace-bot SDK для Python verify встроен. Использование
собственной реализации не требуется — используйте Bot().
Replay protection (рекомендуется)
Отвергать delivery со временем t > 5 минут от current — защита от
replay-атаки на сохранённом delivery.
import time
def verify_with_replay(body, header, secret, max_skew=300):
if not verify(body, header, secret):
return False
parts = dict(p.split("=", 1) for p in header.split(","))
return abs(time.time() - int(parts["t"])) <= max_skew
Persistence (server-side)
Webhook-секрет держится только в памяти процесса API. В БД хранится
лишь его hash, поэтому после рестарта/редеплоя API plaintext-секрет
восстановить неоткуда — доставки временно идут БЕЗ заголовка
X-Reasonspace-Signature (unsigned), пока секрет не будет задан заново.
Чтобы вернуть подпись, пересоздайте webhook (DELETE + POST) и положите
новый WEBHOOK_SECRET в env. Не полагайтесь на гарантированное наличие
подписи: обрабатывайте отсутствие X-Reasonspace-Signature как отдельный
случай (например, отклоняйте или ставьте delivery в ожидание).
Хранение секрета зашифрованным в БД (чтобы он переживал рестарт) — запланированный TODO (Phase 4.5), пока не задеплоено.
Если HMAC не сходится
Тот, что показывался один раз при создании webhook'а в UI?
Не используйте json.loads(body) для verify — нужен сырой byte stream.
body.decode('utf-8') стандарт; при проблемах — log'ните raw bytes.
При включённом replay-check — проверьте NTP-синхронизацию сервера.
Если ничего не помогло — DELETE + POST через UI, новый
WEBHOOK_SECRET положите в env.