From 7d3e8c5b4f18186bb567e88224c99b6843026230 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 11 Jun 2026 18:12:32 +0200 Subject: [PATCH] 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 --- README.md | 22 ++++++++++++++ proofstream.go | 43 ++++++++++++++++++++++++++++ webhook_parity_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 webhook_parity_test.go diff --git a/README.md b/README.md index 6ab7525..b8bc8e9 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,28 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil)) ``` +## Receiving Attesto webhooks + +```go +func handler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + headers := map[string]string{ + "X-Attesto-Timestamp": r.Header.Get("X-Attesto-Timestamp"), + "X-Attesto-Signature": r.Header.Get("X-Attesto-Signature"), + } + if !attesto.VerifyWebhook(body, headers, webhookSecret, 300) { + w.WriteHeader(http.StatusUnauthorized) + return + } + process(body) +} +``` + +Verification recomputes `hmac_sha256(secret, "." + body)` from the +`X-Attesto-Timestamp` / `X-Attesto-Signature` headers, rejects timestamps more +than the allowed skew from now (replay protection), and compares with +`hmac.Equal` (constant time). + ## Operator and Admin Endpoints System-key clients are created with `attesto.NewClient`. Tenant/operator diff --git a/proofstream.go b/proofstream.go index de87d59..ad1a48c 100644 --- a/proofstream.go +++ b/proofstream.go @@ -439,6 +439,49 @@ func sameString(a, b any) bool { return a == nil && b == nil } +func webhookHeader(headers map[string]string, name string) (string, bool) { + for key, value := range headers { + if strings.EqualFold(key, name) { + return value, true + } + } + return "", false +} + +// VerifyWebhook verifies an inbound webhook delivered by Attesto. It reads the +// X-Attesto-Timestamp / X-Attesto-Signature headers (case-insensitive), rejects +// when the timestamp is outside maxSkewS of now, recomputes +// hmac_sha256(secret, "." + body), and compares in constant time. Mirrors +// the backend webhook signing scheme. +func VerifyWebhook(body []byte, headers map[string]string, secret string, maxSkewS int) bool { + return verifyWebhookAt(body, headers, secret, maxSkewS, time.Now().Unix()) +} + +func verifyWebhookAt(body []byte, headers map[string]string, secret string, maxSkewS int, now int64) bool { + timestamp, okTS := webhookHeader(headers, "x-attesto-timestamp") + signature, okSig := webhookHeader(headers, "x-attesto-signature") + if !okTS || !okSig { + return false + } + ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) + if err != nil { + return false + } + skew := now - ts + if skew < 0 { + skew = -skew + } + if skew > int64(maxSkewS) { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(strconv.FormatInt(ts, 10))) + mac.Write([]byte(".")) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) { if timestamp == 0 { timestamp = time.Now().Unix() diff --git a/webhook_parity_test.go b/webhook_parity_test.go new file mode 100644 index 0000000..ce52bab --- /dev/null +++ b/webhook_parity_test.go @@ -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") + } + } +}