package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("MAAT_WEBHOOK_SECRET"))
func expectedSignature(timestamp string, rawBody []byte) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte("v1:" + timestamp + ":"))
mac.Write(rawBody)
return hex.EncodeToString(mac.Sum(nil))
}
func matches(candidate, expected string) bool {
// ConstantTimeCompare is length-safe here because both are hex of the same
// digest; hmac.Equal is constant time for equal lengths and returns false
// otherwise, which is what we want.
return hmac.Equal([]byte(candidate), []byte(expected))
}
func handler(w http.ResponseWriter, r *http.Request) {
// 1. The raw bytes, before any JSON decoding.
rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// 2. A missing, unreadable or stale timestamp is a 401, never a panic.
timestamp := r.Header.Get("X-Signature-Timestamp")
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 {
w.WriteHeader(http.StatusUnauthorized)
return
}
// 3. Constant-time compare — against either signature during a rotation.
expected := expectedSignature(timestamp, rawBody)
if !matches(r.Header.Get("X-Signature"), expected) &&
!matches(r.Header.Get("X-Signature-Previous"), expected) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// 4. Only now parse.
var event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// 5. event_id is stable across retries — use it as your idempotency key.
if event.EventType == "endpoint.challenge" {
var challenge struct {
Challenge string `json:"challenge"`
}
_ = json.Unmarshal(event.Data, &challenge)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(challenge)
return
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/maat", handler)
log.Fatal(http.ListenAndServe(":8443", nil))
}