diff --git a/CLAUDE.md b/CLAUDE.md index f1bc7a6..f6ca724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ go-sdk/ ├── config/ # layered override→env→default settings ├── serde/ # Marshaler/Unmarshaler seam + JSON default + Tristate ├── sse/ # Server-Sent Events (WHATWG) parser -├── webhook/ # placeholder (doc.go only) +├── webhook/ # inbound signature verification (HMAC + timestamp) ├── .golangci.yml Makefile .github/workflows/ci.yml └── CONTRIBUTING.md CLAUDE.md README.md LICENSE ``` diff --git a/README.md b/README.md index 8f5e549..324a3d5 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,9 @@ standard library. | [`config`](./config) | Layered override → environment → default settings resolver; non-failing typed getters. | | [`serde`](./serde) | Serialization seam (Marshaler/Unmarshaler) with a JSON default, plus Tristate for PATCH payloads. | | [`sse`](./sse) | Server-Sent Events (text/event-stream) WHATWG parser. | +| [`webhook`](./webhook) | Inbound webhook signature verification (constant-time HMAC + timestamp tolerance). | | root [`dexpace`](.) | Umbrella `Client` wiring the default policy stack. | -Reserved for upcoming work (placeholder packages today): `webhook`. - ### Pipeline order `dexpace.New` assembles policies outermost-first: diff --git a/doc.go b/doc.go index 60d174a..8dafe29 100644 --- a/doc.go +++ b/doc.go @@ -59,5 +59,8 @@ // The sse package parses Server-Sent Events (text/event-stream) into a // range-over-func iterator of events. // +// The webhook package verifies inbound webhook signatures (constant-time HMAC +// with a timestamp-tolerance window). +// // All of core depends only on the Go standard library. package dexpace diff --git a/docs/superpowers/plans/2026-06-16-webhook.md b/docs/superpowers/plans/2026-06-16-webhook.md new file mode 100644 index 0000000..9b0b487 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-webhook.md @@ -0,0 +1,348 @@ +# Webhook Signature Verification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement constant-time HMAC-SHA256 webhook signature verification with a timestamp-tolerance window in the `webhook` package. + +**Architecture:** A `Verifier` (secret + tolerance) exposes `Verify` (explicit signed payload) and `VerifyTimestamp` (Stripe-style `"."` with a tolerance window). Comparison uses `hmac.Equal` on raw MAC bytes (constant-time). A `Sign` helper and typed sentinel errors round it out. + +**Tech Stack:** Go 1.26+, standard library only (`crypto/hmac`, `crypto/sha256`, `encoding/hex`, `errors`, `strconv`, `time`). Zero third-party dependencies. + +**Conventions every task must follow:** +- MIT license header on every `.go` file before the `package` clause: + ```go + // Copyright (c) 2026 dexpace and Omar Aljarrah. + // Licensed under the MIT License. See LICENSE in the repository root for details. + ``` +- Import groups: stdlib only here. +- Tests use `t.Parallel()`; table-driven where natural; stdlib-only. +- Tools: Go 1.26.3; `gofumpt`/`golangci-lint` NOT installed — use `gofmt`, `go vet`, `go test -race`. +- Run commands from the repo root `/Users/omar/dexpace/go-sdk`. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `webhook/doc.go` (modify) | real package comment | +| `webhook/webhook.go` (new) | `Sign`, `Verifier`, `Verify`, `VerifyTimestamp`, errors, options | +| `webhook/webhook_test.go` (new) | round-trip, mismatch, bad-hex, tolerance tests | +| `doc.go`, `README.md`, `CLAUDE.md` (modify) | document; de-placeholder `webhook` | + +--- + +## Task 1: the `webhook` package + +**Files:** +- Modify: `webhook/doc.go` +- Create: `webhook/webhook.go`, `webhook/webhook_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// webhook/webhook_test.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package webhook_test + +import ( + "errors" + "testing" + "time" + + "github.com/dexpace/go-sdk/webhook" +) + +func TestSignVerifyRoundTrip(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + payload := []byte(`{"event":"ping"}`) + sig := webhook.Sign(secret, payload) + + v := webhook.NewVerifier(secret) + if err := v.Verify(payload, sig); err != nil { + t.Fatalf("Verify of a valid signature: %v", err) + } + + // Tampered payload must fail. + if err := v.Verify([]byte(`{"event":"pong"}`), sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("tampered payload err = %v, want ErrSignatureMismatch", err) + } +} + +func TestVerifyWrongSecret(t *testing.T) { + t.Parallel() + + payload := []byte("body") + sig := webhook.Sign([]byte("right"), payload) + v := webhook.NewVerifier([]byte("wrong")) + if err := v.Verify(payload, sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("wrong secret err = %v, want ErrSignatureMismatch", err) + } +} + +func TestVerifyBadHex(t *testing.T) { + t.Parallel() + + v := webhook.NewVerifier([]byte("s")) + if err := v.Verify([]byte("body"), "not-hex!!"); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("bad hex err = %v, want ErrSignatureMismatch (no panic)", err) + } +} + +func TestVerifyTimestampWithinTolerance(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + + v := webhook.NewVerifier(secret) + // now == ts: within the default 5-minute window. + if err := v.VerifyTimestamp(body, ts, ts, sig); err != nil { + t.Fatalf("VerifyTimestamp within tolerance: %v", err) + } +} + +func TestVerifyTimestampOutsideTolerance(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + v := webhook.NewVerifier(secret) // default 5m tolerance + + // Stale: now is 10 minutes after ts. + if err := v.VerifyTimestamp(body, ts, ts.Add(10*time.Minute), sig); !errors.Is(err, webhook.ErrTimestampOutsideTolerance) { + t.Fatalf("stale err = %v, want ErrTimestampOutsideTolerance", err) + } + // Future-dated: now is 10 minutes before ts. + if err := v.VerifyTimestamp(body, ts, ts.Add(-10*time.Minute), sig); !errors.Is(err, webhook.ErrTimestampOutsideTolerance) { + t.Fatalf("future err = %v, want ErrTimestampOutsideTolerance", err) + } +} + +func TestVerifyTimestampZeroToleranceSkipsWindow(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + + v := webhook.NewVerifier(secret, webhook.WithTolerance(0)) + // now far from ts, but tolerance disabled: only the signature matters. + if err := v.VerifyTimestamp(body, ts, ts.Add(48*time.Hour), sig); err != nil { + t.Fatalf("zero tolerance should skip the window: %v", err) + } +} + +func TestVerifyTimestampSignatureMismatch(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + ts := time.Unix(1_700_000_000, 0) + // Signature over the wrong body. + sig := webhook.Sign(secret, []byte("1700000000.other")) + + v := webhook.NewVerifier(secret) + if err := v.VerifyTimestamp([]byte("payload"), ts, ts, sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("err = %v, want ErrSignatureMismatch", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./webhook/ -v` +Expected: FAIL — `webhook.Sign`/`NewVerifier`/etc. undefined. + +- [ ] **Step 3: Replace `webhook/doc.go`** + +```go +// webhook/doc.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Package webhook verifies inbound webhook signatures. A [Verifier] checks an +// HMAC-SHA256 signature against a payload in constant time, and VerifyTimestamp +// adds a tolerance window over the common "." signed-payload scheme to +// defeat replay. +package webhook +``` + +- [ ] **Step 4: Create `webhook/webhook.go`** + +```go +// webhook/webhook.go +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "time" +) + +// defaultTolerance is the clock skew VerifyTimestamp allows unless overridden. +const defaultTolerance = 5 * time.Minute + +// ErrSignatureMismatch is returned when a signature does not match the payload. +var ErrSignatureMismatch = errors.New("webhook: signature mismatch") + +// ErrTimestampOutsideTolerance is returned when a timestamped payload is outside +// the verifier's tolerance window. +var ErrTimestampOutsideTolerance = errors.New("webhook: timestamp outside tolerance") + +// Sign returns the lowercase hex-encoded HMAC-SHA256 of payload keyed by secret. +func Sign(secret, payload []byte) string { + return hex.EncodeToString(mac(secret, payload)) +} + +func mac(secret, payload []byte) []byte { + h := hmac.New(sha256.New, secret) + h.Write(payload) + return h.Sum(nil) +} + +// Verifier verifies HMAC-SHA256 webhook signatures. Build one with [NewVerifier]; +// it is safe for concurrent use. +type Verifier struct { + secret []byte + tolerance time.Duration +} + +// Option configures a [Verifier]. +type Option func(*Verifier) + +// WithTolerance sets the allowed clock skew for [Verifier.VerifyTimestamp]. The +// default is five minutes; a value <= 0 disables the timestamp check. +func WithTolerance(d time.Duration) Option { + return func(v *Verifier) { v.tolerance = d } +} + +// NewVerifier returns a Verifier keyed by secret. +func NewVerifier(secret []byte, opts ...Option) *Verifier { + v := &Verifier{secret: secret, tolerance: defaultTolerance} + for _, opt := range opts { + opt(v) + } + return v +} + +// Verify reports whether sigHex is a valid hex-encoded HMAC-SHA256 signature of +// payload, compared in constant time. It returns nil on a match and +// ErrSignatureMismatch otherwise, including when sigHex is not valid hex. +func (v *Verifier) Verify(payload []byte, sigHex string) error { + provided, err := hex.DecodeString(sigHex) + if err != nil { + return ErrSignatureMismatch + } + if !hmac.Equal(provided, mac(v.secret, payload)) { + return ErrSignatureMismatch + } + return nil +} + +// VerifyTimestamp verifies the common scheme in which the signed payload is the +// Unix timestamp, a ".", and the body. It first checks that timestamp is within +// the configured tolerance of now (unless the tolerance is <= 0), then verifies +// sigHex against "." + body. +func (v *Verifier) VerifyTimestamp(body []byte, timestamp, now time.Time, sigHex string) error { + if v.tolerance > 0 { + diff := now.Sub(timestamp) + if diff < 0 { + diff = -diff + } + if diff > v.tolerance { + return ErrTimestampOutsideTolerance + } + } + signed := strconv.FormatInt(timestamp.Unix(), 10) + "." + string(body) + return v.Verify([]byte(signed), sigHex) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./webhook/ -v` +Expected: PASS — all sign/verify/tolerance tests. + +- [ ] **Step 6: Commit** + +```bash +git add webhook/doc.go webhook/webhook.go webhook/webhook_test.go +git commit -m "feat(webhook): add constant-time HMAC signature verification" +``` + +--- + +## Task 2: docs and full gate + +**Files:** +- Modify: `doc.go`, `README.md`, `CLAUDE.md` + +- [ ] **Step 1: Mention webhook in `doc.go`** + +Read `doc.go`. Within the `package dexpace` doc comment (single contiguous `//` +block; no second package clause / no duplicate header), add: + +```go +// The webhook package verifies inbound webhook signatures (constant-time HMAC +// with a timestamp-tolerance window). +``` + +- [ ] **Step 2: Update `README.md`** + +Read `README.md`. Add a `webhook` row to the architecture/package table (matching +the column/link style): "Inbound webhook signature verification (constant-time +HMAC + timestamp tolerance)." If a "reserved/placeholder packages" line still +lists `webhook`, remove it (this is the last placeholder; remove the whole line or +the `webhook` entry as appropriate). + +- [ ] **Step 3: De-placeholder `webhook` in `CLAUDE.md`** + +Read `CLAUDE.md`. In the Repository Layout tree, remove `webhook/` from the +placeholder line (it is the last placeholder — if the line becomes empty, remove +the line) and add a `webhook/` entry near the other packages: +`webhook/ # inbound signature verification (HMAC + timestamp)`. + +- [ ] **Step 4: Run the full gate** + +Run: +```bash +gofmt -l . +go vet ./... +go test -race ./... +``` +Expected: `gofmt -l .` prints nothing; `go vet` clean; every package passes under +the race detector (`webhook` now has tests — no `[no test files]` placeholders +should remain except `header` if it has no test... note `header` does have a test +now; only confirm all `ok`). + +- [ ] **Step 5: Commit** + +```bash +git add doc.go README.md CLAUDE.md +git commit -m "docs: document webhook package and de-placeholder it" +``` + +--- + +## Self-Review notes (for the implementer) + +- **Spec coverage:** `Sign`, `Verifier`, `Verify`, `VerifyTimestamp`, options, sentinel errors (Task 1); docs + de-placeholder (Task 2). +- **Type consistency:** `webhook.Sign(secret, payload)`, `webhook.NewVerifier(secret, opts...)`, `webhook.WithTolerance(d)`, `(*Verifier).Verify(payload, sigHex)`, `(*Verifier).VerifyTimestamp(body, timestamp, now, sigHex)`, `webhook.ErrSignatureMismatch`, `webhook.ErrTimestampOutsideTolerance` are used identically across tasks. +- **Constant-time:** comparison is `hmac.Equal` on the raw MAC bytes; bad hex maps to mismatch without a leak. +- **Timestamp window:** absolute difference, so stale and future events both reject; `WithTolerance(0)` disables the window. +- **`make check`** green before opening the PR. diff --git a/docs/superpowers/specs/2026-06-16-webhook-design.md b/docs/superpowers/specs/2026-06-16-webhook-design.md new file mode 100644 index 0000000..d16753f --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-webhook-design.md @@ -0,0 +1,123 @@ +# Webhook signature verification — design + +**Date:** 2026-06-16 +**Status:** Approved (standing delegation); ready for implementation planning +**Subsystem:** #10 (final) of the Go SDK platform-parity roadmap + +## Context + +The `webhook` package is a placeholder. Java/Python verify inbound webhook +signatures with constant-time HMAC comparison and a timestamp-tolerance window to +defeat replay. This subsystem brings that to Go. + +## Decisions + +1. **HMAC-SHA256, constant-time.** Verification computes `HMAC-SHA256(secret, + payload)` and compares against the supplied signature with `hmac.Equal` + (constant-time over the raw MAC bytes, not the hex strings). +2. **Two entry points.** `Verifier.Verify` for an explicit signed payload, and + `Verifier.VerifyTimestamp` for the common Stripe-style scheme (signed payload = + `"."`) with a tolerance window. +3. **Injected `now`.** `VerifyTimestamp` takes `now time.Time` rather than calling + `time.Now`, for deterministic tests and caller control. +4. **Typed sentinel errors** for `errors.Is`: signature mismatch and + timestamp-outside-tolerance. +5. **A `Sign` helper** (hex HMAC-SHA256) for symmetry/testing and for callers who + send signed payloads. + +## Architecture + +### `webhook` package (stdlib-only) + +```go +// ErrSignatureMismatch is returned when a signature does not match the payload. +var ErrSignatureMismatch = errors.New("webhook: signature mismatch") + +// ErrTimestampOutsideTolerance is returned when a timestamped payload is outside +// the verifier's tolerance window. +var ErrTimestampOutsideTolerance = errors.New("webhook: timestamp outside tolerance") + +// Sign returns the lowercase hex-encoded HMAC-SHA256 of payload keyed by secret. +func Sign(secret, payload []byte) string + +// Verifier verifies HMAC-SHA256 webhook signatures. The zero value is not usable; +// build one with NewVerifier. It is safe for concurrent use. +type Verifier struct { + secret []byte + tolerance time.Duration +} + +// Option configures a Verifier. +type Option func(*Verifier) + +// WithTolerance sets the allowed clock skew for VerifyTimestamp. The default is +// five minutes; a value <= 0 disables the timestamp check. +func WithTolerance(d time.Duration) Option + +// NewVerifier returns a Verifier keyed by secret. +func NewVerifier(secret []byte, opts ...Option) *Verifier + +// Verify reports whether sigHex is a valid lowercase/uppercase hex HMAC-SHA256 +// signature of payload, compared in constant time. It returns nil on a match and +// ErrSignatureMismatch otherwise (including when sigHex is not valid hex). +func (v *Verifier) Verify(payload []byte, sigHex string) error + +// VerifyTimestamp implements the common scheme: the signed payload is the Unix +// timestamp, a ".", and the body. It first checks that timestamp is within the +// configured tolerance of now (unless tolerance <= 0), then verifies sigHex +// against "." + body. +func (v *Verifier) VerifyTimestamp(body []byte, timestamp, now time.Time, sigHex string) error +``` + +### Verification detail + +- `Verify`: `expected := hmacSHA256(secret, payload)` (raw bytes); + `provided, err := hex.DecodeString(sigHex)`; if `err != nil` → `ErrSignatureMismatch`; + if `!hmac.Equal(provided, expected)` → `ErrSignatureMismatch`; else nil. + `hmac.Equal` is constant-time and length-safe. +- `VerifyTimestamp`: if `tolerance > 0` and `abs(now.Sub(timestamp)) > tolerance` + → `ErrTimestampOutsideTolerance`; build `signed := strconv.FormatInt(timestamp.Unix(),10) + "." + string(body)`; + return `Verify([]byte(signed), sigHex)`. +- `Sign`: `hex.EncodeToString(hmacSHA256(secret, payload))`. + +## Edge cases + +- Invalid hex in `sigHex` → `ErrSignatureMismatch` (never panics, no information + leak beyond "mismatch"). +- Wrong-length signature → `hmac.Equal` returns false → `ErrSignatureMismatch`. +- `WithTolerance(0)` (or negative) → the timestamp window is skipped; only the + signature is checked. +- `now` before or after `timestamp`: the check uses the absolute difference, so + both a stale event and a future-dated one outside the window are rejected. +- Empty secret/payload are allowed (HMAC of empty inputs is well-defined); not + validated. +- `Sign` and `Verify` round-trip: `verify(payload, Sign(secret, payload)) == nil`. + +## Package layout + +| Path | Change | +|---|---| +| `webhook/doc.go` (modify) | real package comment | +| `webhook/webhook.go` (new) | `Sign`, `Verifier`, `Verify`, `VerifyTimestamp`, errors, options | +| `webhook/webhook_test.go` (new) | round-trip, mismatch, bad-hex, tolerance tests | +| `doc.go`, `README.md`, `CLAUDE.md` | document; de-placeholder `webhook` | + +## Testing + +- `Sign`/`Verify` round-trip succeeds; a tampered payload → `ErrSignatureMismatch`. +- A wrong secret → `ErrSignatureMismatch`. +- Invalid hex signature → `ErrSignatureMismatch` (no panic). +- `VerifyTimestamp`: within tolerance → nil; outside (stale and future) → + `ErrTimestampOutsideTolerance`; `WithTolerance(0)` skips the window; the signed + payload format `"."` is what `Sign` must produce for a match. +- Constant-time path uses `hmac.Equal` (asserted indirectly via correctness; the + property itself is a stdlib guarantee). +- Table-driven, parallel; stdlib-only; `gofmt`/`go vet`/`go test -race` clean. + +## Out of scope (deferred) + +- Provider-specific header parsing (e.g. Stripe's `t=…,v1=…` format). Callers parse + their provider's header and pass the timestamp + signature to `VerifyTimestamp`; + a thin parser can be added per provider later if needed. +- Signature schemes other than HMAC-SHA256 (e.g. Ed25519). Add when a concrete + need arises. diff --git a/webhook/doc.go b/webhook/doc.go index e9f4c9e..3ff52ce 100644 --- a/webhook/doc.go +++ b/webhook/doc.go @@ -1,9 +1,8 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. -// Package webhook will provide webhook signature verification (constant-time -// HMAC comparison, timestamp tolerance) for inbound callbacks. -// -// Status: placeholder. The package is reserved as part of the initial repository -// structure and has no exported API yet. +// Package webhook verifies inbound webhook signatures. A [Verifier] checks an +// HMAC-SHA256 signature against a payload in constant time, and VerifyTimestamp +// adds a tolerance window over the common "." signed-payload scheme to +// defeat replay. package webhook diff --git a/webhook/webhook.go b/webhook/webhook.go new file mode 100644 index 0000000..b3adf4f --- /dev/null +++ b/webhook/webhook.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "time" +) + +// defaultTolerance is the clock skew VerifyTimestamp allows unless overridden. +const defaultTolerance = 5 * time.Minute + +// ErrSignatureMismatch is returned when a signature does not match the payload. +var ErrSignatureMismatch = errors.New("webhook: signature mismatch") + +// ErrTimestampOutsideTolerance is returned when a timestamped payload is outside +// the verifier's tolerance window. +var ErrTimestampOutsideTolerance = errors.New("webhook: timestamp outside tolerance") + +// Sign returns the lowercase hex-encoded HMAC-SHA256 of payload keyed by secret. +func Sign(secret, payload []byte) string { + return hex.EncodeToString(mac(secret, payload)) +} + +func mac(secret, payload []byte) []byte { + h := hmac.New(sha256.New, secret) + h.Write(payload) + return h.Sum(nil) +} + +// Verifier verifies HMAC-SHA256 webhook signatures. Build one with [NewVerifier]; +// it is safe for concurrent use. +type Verifier struct { + secret []byte + tolerance time.Duration +} + +// Option configures a [Verifier]. +type Option func(*Verifier) + +// WithTolerance sets the allowed clock skew for [Verifier.VerifyTimestamp]. The +// default is five minutes. +// +// WARNING: a value <= 0 DISABLES the timestamp check entirely (it does not make +// it stricter), so the verifier will accept any timestamp. Use a positive +// duration to keep replay protection. +func WithTolerance(d time.Duration) Option { + return func(v *Verifier) { v.tolerance = d } +} + +// NewVerifier returns a Verifier keyed by secret. +func NewVerifier(secret []byte, opts ...Option) *Verifier { + v := &Verifier{secret: secret, tolerance: defaultTolerance} + for _, opt := range opts { + opt(v) + } + return v +} + +// Verify reports whether sigHex is a valid hex-encoded HMAC-SHA256 signature of +// payload, compared in constant time. It returns nil on a match and +// ErrSignatureMismatch otherwise, including when sigHex is not valid hex. +func (v *Verifier) Verify(payload []byte, sigHex string) error { + return verifyMAC(mac(v.secret, payload), sigHex) +} + +// VerifyTimestamp verifies the common scheme in which the signed payload is the +// Unix timestamp, a ".", and the body. It first checks that timestamp is within +// the configured tolerance of now (unless the tolerance is <= 0), then verifies +// sigHex against "." + body. +func (v *Verifier) VerifyTimestamp(body []byte, timestamp, now time.Time, sigHex string) error { + if v.tolerance > 0 { + diff := now.Sub(timestamp) + if diff < 0 { + diff = -diff + } + if diff > v.tolerance { + return ErrTimestampOutsideTolerance + } + } + h := hmac.New(sha256.New, v.secret) + h.Write([]byte(strconv.FormatInt(timestamp.Unix(), 10))) + h.Write([]byte{'.'}) + h.Write(body) + return verifyMAC(h.Sum(nil), sigHex) +} + +// verifyMAC compares the hex signature sigHex against the already-computed MAC in +// constant time. Invalid hex maps to ErrSignatureMismatch (no leak). +func verifyMAC(expected []byte, sigHex string) error { + provided, err := hex.DecodeString(sigHex) + if err != nil { + return ErrSignatureMismatch + } + if !hmac.Equal(provided, expected) { + return ErrSignatureMismatch + } + return nil +} diff --git a/webhook/webhook_test.go b/webhook/webhook_test.go new file mode 100644 index 0000000..87a647e --- /dev/null +++ b/webhook/webhook_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package webhook_test + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/dexpace/go-sdk/webhook" +) + +func TestSignVerifyRoundTrip(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + payload := []byte(`{"event":"ping"}`) + sig := webhook.Sign(secret, payload) + + v := webhook.NewVerifier(secret) + if err := v.Verify(payload, sig); err != nil { + t.Fatalf("Verify of a valid signature: %v", err) + } + + if err := v.Verify([]byte(`{"event":"pong"}`), sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("tampered payload err = %v, want ErrSignatureMismatch", err) + } +} + +func TestVerifyWrongSecret(t *testing.T) { + t.Parallel() + + payload := []byte("body") + sig := webhook.Sign([]byte("right"), payload) + v := webhook.NewVerifier([]byte("wrong")) + if err := v.Verify(payload, sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("wrong secret err = %v, want ErrSignatureMismatch", err) + } +} + +func TestVerifyBadHex(t *testing.T) { + t.Parallel() + + v := webhook.NewVerifier([]byte("s")) + if err := v.Verify([]byte("body"), "not-hex!!"); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("bad hex err = %v, want ErrSignatureMismatch (no panic)", err) + } +} + +func TestVerifyTimestampWithinTolerance(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + + v := webhook.NewVerifier(secret) + if err := v.VerifyTimestamp(body, ts, ts, sig); err != nil { + t.Fatalf("VerifyTimestamp within tolerance: %v", err) + } +} + +func TestVerifyTimestampOutsideTolerance(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + v := webhook.NewVerifier(secret) + + if err := v.VerifyTimestamp(body, ts, ts.Add(10*time.Minute), sig); !errors.Is(err, webhook.ErrTimestampOutsideTolerance) { + t.Fatalf("stale err = %v, want ErrTimestampOutsideTolerance", err) + } + if err := v.VerifyTimestamp(body, ts, ts.Add(-10*time.Minute), sig); !errors.Is(err, webhook.ErrTimestampOutsideTolerance) { + t.Fatalf("future err = %v, want ErrTimestampOutsideTolerance", err) + } +} + +func TestVerifyTimestampZeroToleranceSkipsWindow(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + body := []byte("payload") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000."+string(body))) + + v := webhook.NewVerifier(secret, webhook.WithTolerance(0)) + if err := v.VerifyTimestamp(body, ts, ts.Add(48*time.Hour), sig); err != nil { + t.Fatalf("zero tolerance should skip the window: %v", err) + } +} + +func TestVerifyTimestampSignatureMismatch(t *testing.T) { + t.Parallel() + + secret := []byte("s3cr3t") + ts := time.Unix(1_700_000_000, 0) + sig := webhook.Sign(secret, []byte("1700000000.other")) + + v := webhook.NewVerifier(secret) + if err := v.VerifyTimestamp([]byte("payload"), ts, ts, sig); !errors.Is(err, webhook.ErrSignatureMismatch) { + t.Fatalf("err = %v, want ErrSignatureMismatch", err) + } +} + +func TestSignKnownVector(t *testing.T) { + t.Parallel() + + // Canonical HMAC-SHA256 test vector: + // HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog"). + const want = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" + got := webhook.Sign([]byte("key"), []byte("The quick brown fox jumps over the lazy dog")) + if got != want { + t.Fatalf("Sign known vector = %q, want %q", got, want) + } +} + +func TestSignOutputFormat(t *testing.T) { + t.Parallel() + + sig := webhook.Sign([]byte("secret"), []byte("payload")) + if len(sig) != 64 { + t.Fatalf("Sign length = %d, want 64 hex chars", len(sig)) + } + if sig != strings.ToLower(sig) { + t.Fatalf("Sign output %q is not lowercase hex", sig) + } +}