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:
22
README.md
22
README.md
@@ -129,6 +129,28 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead
|
|||||||
client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil))
|
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, "<timestamp>." + 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
|
## Operator and Admin Endpoints
|
||||||
|
|
||||||
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
||||||
|
|||||||
@@ -439,6 +439,49 @@ func sameString(a, b any) bool {
|
|||||||
return a == nil && b == nil
|
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, "<ts>." + 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) {
|
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
||||||
if timestamp == 0 {
|
if timestamp == 0 {
|
||||||
timestamp = time.Now().Unix()
|
timestamp = time.Now().Unix()
|
||||||
|
|||||||
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