sdk(P1.4): inbound webhook verification helper in all three SDKs
Customers receiving Attesto webhook deliveries can now verify them with one
call, mirroring backend/app/services/webhooks/signing.py exactly: read the
X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), reject
when abs(now - ts) > max_skew_s (300 s default, replay protection), recompute
hex(hmac_sha256(secret, f"{timestamp}.{body}")), constant-time compare
(hmac.compare_digest / charcode-XOR fold over equal-length hex / hmac.Equal).
- Python: attesto.verify_webhook(body=, headers=, secret=, max_skew_s=, now=)
- TypeScript: verifyWebhook({ body, headers, secret, maxSkewS, now }) via
WebCrypto HMAC (edge-safe)
- Go: VerifyWebhook(body, headers, secret, maxSkewS)
New corpus golden-vectors/sdk-parity/webhook.json (valid, within-skew,
skewed-timestamp, bad-signature, tampered-body, wrong-secret,
non-numeric-timestamp) with backend-derived signatures; all three SDKs agree
on every case. READMEs gain a "Receiving Attesto webhooks" example.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
65
webhook_parity_test.go
Normal file
65
webhook_parity_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package attesto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type webhookParityVectors struct {
|
||||
Cases []struct {
|
||||
ID string `json:"id"`
|
||||
Secret string `json:"secret"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Now int64 `json:"now"`
|
||||
MaxSkewS int `json:"max_skew_s"`
|
||||
ExpectOK bool `json:"expect_ok"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
|
||||
func TestParityWebhookVerification(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read webhook vectors: %v", err)
|
||||
}
|
||||
var v webhookParityVectors
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("decode webhook vectors: %v", err)
|
||||
}
|
||||
for _, c := range v.Cases {
|
||||
ok := verifyWebhookAt([]byte(c.Body), c.Headers, c.Secret, c.MaxSkewS, c.Now)
|
||||
if ok != c.ExpectOK {
|
||||
t.Errorf("webhook %s: ok=%v want %v", c.ID, ok, c.ExpectOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHeaderLookupIsCaseInsensitive(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "webhook.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read webhook vectors: %v", err)
|
||||
}
|
||||
var v webhookParityVectors
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("decode webhook vectors: %v", err)
|
||||
}
|
||||
for _, c := range v.Cases {
|
||||
if c.ID != "valid" {
|
||||
continue
|
||||
}
|
||||
lowered := map[string]string{}
|
||||
for key, value := range c.Headers {
|
||||
lowered[key] = value
|
||||
}
|
||||
// Re-key with different casing.
|
||||
lowered["X-ATTESTO-TIMESTAMP"] = lowered["X-Attesto-Timestamp"]
|
||||
delete(lowered, "X-Attesto-Timestamp")
|
||||
if !verifyWebhookAt([]byte(c.Body), lowered, c.Secret, c.MaxSkewS, c.Now) {
|
||||
t.Error("case-insensitive header lookup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user