// Package webhookreceiver demonstrates strict raw-body verification. Receivers // must durably deduplicate event IDs together with their business effect before // returning success. Delivery IDs change on replay; event IDs do not. package webhookreceiver import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "net/http" "strconv" "strings" "time" ) var ErrSignature = errors.New("invalid webhook signature") type Key struct { Secret []byte ValidUntil time.Time } type Identity struct { Delivery, Event, Attempt, Key string Number int } // Verify accepts current keys and explicitly time-limited previous keys. The // receiver sets a previous key's ValidUntil no later than 24h after rotation. func Verify(headers http.Header, raw []byte, now time.Time, keys map[string]Key) (Identity, error) { var identity Identity if len(raw) > 65536 { return identity, ErrSignature } fields := []string{} for _, name := range []string{"Agent-Hotel-Timestamp", "Agent-Hotel-Delivery-Id", "Agent-Hotel-Event-Id", "Agent-Hotel-Attempt-Id", "Agent-Hotel-Attempt", "Agent-Hotel-Key-Id", "Agent-Hotel-Signature"} { values := headers.Values(name) if len(values) != 1 || len(values[0]) == 0 || len(values[0]) > 128 || strings.ContainsAny(values[0], "\r\n\x00") { return identity, ErrSignature } fields = append(fields, values[0]) } stamp, e := strconv.ParseInt(fields[0], 10, 64) if e != nil || strconv.FormatInt(stamp, 10) != fields[0] { return identity, ErrSignature } timestamp := time.Unix(stamp, 0) if timestamp.Before(now.Add(-5*time.Minute)) || timestamp.After(now.Add(5*time.Minute)) { return identity, ErrSignature } number, e := strconv.Atoi(fields[4]) if e != nil || number < 1 || number > 11 || strconv.Itoa(number) != fields[4] { return identity, ErrSignature } key, ok := keys[fields[5]] if !ok || len(key.Secret) == 0 || (!key.ValidUntil.IsZero() && !now.Before(key.ValidUntil)) { return identity, ErrSignature } if len(fields[6]) != 67 || !strings.HasPrefix(fields[6], "v1=") || strings.ToLower(fields[6]) != fields[6] { return identity, ErrSignature } provided, e := hex.DecodeString(fields[6][3:]) if e != nil { return identity, ErrSignature } h := hmac.New(sha256.New, key.Secret) h.Write([]byte("agent-hotel-webhook-v1\n" + strings.Join(fields[:5], "\n") + "\n")) h.Write(raw) if !hmac.Equal(provided, h.Sum(nil)) { return identity, ErrSignature } return Identity{Delivery: fields[1], Event: fields[2], Attempt: fields[3], Key: fields[5], Number: number}, nil }