Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
348 changes: 348 additions & 0 deletions docs/superpowers/plans/2026-06-16-webhook.md
Original file line number Diff line number Diff line change
@@ -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 `"<unix>.<body>"` 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 "<unix>.<body>" 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 "<unix>." + 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.
Loading
Loading