-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhook.go
More file actions
205 lines (174 loc) · 6.68 KB
/
webhook.go
File metadata and controls
205 lines (174 loc) · 6.68 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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
package lettermint
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
// DefaultWebhookTolerance is the default timestamp tolerance for webhook verification.
// Webhooks with timestamps older than this will be rejected.
DefaultWebhookTolerance = 5 * time.Minute
// DefaultWebhookMaxBodyBytes is the maximum request body size accepted by VerifyWebhookFromRequest.
DefaultWebhookMaxBodyBytes int64 = 50 << 20
// HeaderSignature is the webhook signature header name.
HeaderSignature = "X-Lettermint-Signature"
// HeaderDelivery is the webhook delivery timestamp header name.
HeaderDelivery = "X-Lettermint-Delivery"
)
// VerifyWebhook verifies a webhook signature and returns the parsed event.
//
// The signature format is: t={timestamp},v1={hmac_sha256_hex}
// The HMAC is computed over: {timestamp}.{payload}
//
// Parameters:
// - signature: The X-Lettermint-Signature header value
// - payload: The raw request body
// - deliveryTimestamp: The X-Lettermint-Delivery header value (Unix timestamp), or 0 to skip cross-validation
// - signingSecret: Your webhook signing secret from the Lettermint dashboard
// - tolerance: Maximum age of the webhook timestamp (use DefaultWebhookTolerance)
//
// Returns the parsed webhook event or an error if verification fails.
func VerifyWebhook(signature string, payload []byte, deliveryTimestamp int64, signingSecret string, tolerance time.Duration) (*WebhookEvent, error) {
if signingSecret == "" {
return nil, fmt.Errorf("%w: signing secret is required", ErrInvalidWebhookSignature)
}
if signature == "" {
return nil, fmt.Errorf("%w: signature is required", ErrInvalidWebhookSignature)
}
// Parse signature: t={timestamp},v1={hash}
sigTimestamp, sigHash, err := parseSignature(signature)
if err != nil {
return nil, err
}
// Cross-validate timestamp if provided
if deliveryTimestamp != 0 && deliveryTimestamp != sigTimestamp {
return nil, fmt.Errorf("%w: timestamp mismatch between signature and delivery headers", ErrInvalidWebhookSignature)
}
// Check timestamp tolerance
now := time.Now().Unix()
diff := now - sigTimestamp
if diff < 0 {
diff = -diff
}
if time.Duration(diff)*time.Second > tolerance {
return nil, fmt.Errorf("%w: timestamp %d is %d seconds old (tolerance: %v)",
ErrWebhookTimestampExpired, sigTimestamp, diff, tolerance)
}
// Compute expected signature
signedPayload := fmt.Sprintf("%d.%s", sigTimestamp, string(payload))
expectedHash := computeHMAC([]byte(signedPayload), signingSecret)
// Constant-time comparison to prevent timing attacks
if !secureCompare(sigHash, expectedHash) {
return nil, fmt.Errorf("%w: signature verification failed", ErrInvalidWebhookSignature)
}
// Parse webhook event
var event WebhookEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, fmt.Errorf("failed to parse webhook payload: %w", err)
}
event.RawPayload = payload
return &event, nil
}
// VerifyWebhookFromRequest verifies a webhook from an HTTP request.
//
// This is a convenience function that extracts the signature and payload
// from the request and calls VerifyWebhook. The request body is limited to
// DefaultWebhookMaxBodyBytes. Use VerifyWebhookFromRequestWithMaxBodyBytes
// to configure a different limit.
//
// Note: This function reads and closes the request body.
//
// Example:
//
// func webhookHandler(w http.ResponseWriter, r *http.Request) {
// event, err := lettermint.VerifyWebhookFromRequest(r, "your-signing-secret", lettermint.DefaultWebhookTolerance)
// if err != nil {
// http.Error(w, "Invalid signature", http.StatusUnauthorized)
// return
// }
// // Process event...
// }
func VerifyWebhookFromRequest(r *http.Request, signingSecret string, tolerance time.Duration) (*WebhookEvent, error) {
return VerifyWebhookFromRequestWithMaxBodyBytes(r, signingSecret, tolerance, DefaultWebhookMaxBodyBytes)
}
// VerifyWebhookFromRequestWithMaxBodyBytes verifies a webhook from an HTTP request
// using a caller-provided maximum request body size.
//
// This is useful when your application reads the max body size from environment
// or config. The SDK keeps that configuration explicit instead of reading .env
// files directly.
func VerifyWebhookFromRequestWithMaxBodyBytes(r *http.Request, signingSecret string, tolerance time.Duration, maxBodyBytes int64) (*WebhookEvent, error) {
if maxBodyBytes <= 0 {
return nil, fmt.Errorf("%w: webhook max body bytes must be positive", ErrInvalidRequest)
}
signature := r.Header.Get(HeaderSignature)
if signature == "" {
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidWebhookSignature, HeaderSignature)
}
deliveryHeader := r.Header.Get(HeaderDelivery)
var deliveryTimestamp int64
if deliveryHeader != "" {
var err error
deliveryTimestamp, err = strconv.ParseInt(deliveryHeader, 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: invalid %s header value", ErrInvalidWebhookSignature, HeaderDelivery)
}
}
body := http.MaxBytesReader(nil, r.Body, maxBodyBytes)
defer body.Close()
payload, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
return VerifyWebhook(signature, payload, deliveryTimestamp, signingSecret, tolerance)
}
// parseSignature parses the signature header value.
// Expected format: t={timestamp},v1={hash}
func parseSignature(signature string) (timestamp int64, hash string, err error) {
parts := strings.Split(signature, ",")
if len(parts) < 2 {
return 0, "", fmt.Errorf("%w: invalid signature format, expected t={timestamp},v1={hash}", ErrInvalidWebhookSignature)
}
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "t":
timestamp, err = strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, "", fmt.Errorf("%w: invalid timestamp in signature", ErrInvalidWebhookSignature)
}
case "v1":
hash = value
}
}
if timestamp == 0 {
return 0, "", fmt.Errorf("%w: missing timestamp (t=) in signature", ErrInvalidWebhookSignature)
}
if hash == "" {
return 0, "", fmt.Errorf("%w: missing hash (v1=) in signature", ErrInvalidWebhookSignature)
}
return timestamp, hash, nil
}
// computeHMAC computes HMAC-SHA256 and returns the hex-encoded string.
func computeHMAC(data []byte, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
// secureCompare performs constant-time string comparison to prevent timing attacks.
func secureCompare(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}