Repository navigation
Expand file tree
/
Copy pathwebhooks_verify.go
More file actions
83 lines (74 loc) · 3.39 KB
/
Copy pathwebhooks_verify.go
File metadata and controls
83 lines (74 loc) · 3.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package qeetnotify
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/qeetgroup/qeet-notify-go/internal/constants"
)
// Webhook delivery headers set by the Qeet Notify outbound-webhook worker.
const (
// WebhookSignatureHeader carries "sha256=<hex>" — HMAC-SHA256 of the raw
// body keyed by the subscription's signing secret.
WebhookSignatureHeader = constants.HeaderWebhookSignature // X-Qeet-Signature-256
// WebhookTimestampHeader carries the Unix timestamp of the delivery.
WebhookTimestampHeader = constants.HeaderWebhookTimestamp // X-Qeet-Timestamp
signaturePrefix = "sha256="
maxWebhookBytes = 1 << 20 // 1 MiB cap on inbound webhook bodies
)
// WebhookEvent is a verified inbound webhook delivery. Payload is the exact
// bytes whose signature was verified — decode it with Data.
type WebhookEvent struct {
// Payload is the raw, verified request body.
Payload json.RawMessage
// Timestamp is the X-Qeet-Timestamp header value (Unix seconds as sent),
// empty if the header was absent.
Timestamp string
}
// Data unmarshals the verified payload into v.
func (e *WebhookEvent) Data(v any) error { return json.Unmarshal(e.Payload, v) }
// VerifyWebhookSignature recomputes HMAC-SHA256(secret, payload) and compares
// it, in constant time, against the "sha256=<hex>" value from the
// X-Qeet-Signature-256 header. It returns nil when the signature is valid.
// Always verify against the RAW request bytes — never a re-serialized body.
func VerifyWebhookSignature(payload []byte, signatureHeader, secret string) error {
if secret == "" {
return &Error{Code: "invalid_signature", Message: "webhook secret is empty"}
}
if !strings.HasPrefix(signatureHeader, signaturePrefix) {
return &Error{Code: "invalid_signature", Message: "signature header missing sha256= prefix"}
}
got := strings.TrimPrefix(signatureHeader, signaturePrefix)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(got), []byte(want)) {
return &Error{Code: "invalid_signature", Message: "webhook signature mismatch"}
}
return nil
}
// ConstructEvent verifies an inbound webhook and returns the parsed event. Pass
// the raw request body, the X-Qeet-Signature-256 and X-Qeet-Timestamp header
// values, and the subscription's signing secret (shown once at create time).
func ConstructEvent(payload []byte, signatureHeader, timestampHeader, secret string) (*WebhookEvent, error) {
if err := VerifyWebhookSignature(payload, signatureHeader, secret); err != nil {
return nil, err
}
// Copy so callers can reuse the input buffer without mutating the event.
buf := make(json.RawMessage, len(payload))
copy(buf, payload)
return &WebhookEvent{Payload: buf, Timestamp: timestampHeader}, nil
}
// ConstructEventFromRequest reads, verifies, and parses a webhook straight from
// an *http.Request — the ergonomic entry point for an HTTP handler. It consumes
// r.Body (capped at 1 MiB) and reads the signature/timestamp headers for you.
func ConstructEventFromRequest(r *http.Request, secret string) (*WebhookEvent, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBytes))
if err != nil {
return nil, &Error{Code: "read_error", Message: "reading webhook body: " + err.Error()}
}
return ConstructEvent(body, r.Header.Get(WebhookSignatureHeader), r.Header.Get(WebhookTimestampHeader), secret)
}