- webhook.go: HMAC-SHA256 WebhookSigner matching PHP WebhookSignature — sign/verify, X-Webhook-Signature / X-Webhook-Timestamp headers, VerifyRequest middleware helper, 5-minute default tolerance, secret generator (RFC §6) - sunset.go: ApiSunsetWith(date, replacement, opts...) + WithSunsetNoticeURL; ApiSunset now emits API-Suggested-Replacement when replacement set; RouteDescription.NoticeURL surfaces API-Deprecation-Notice-URL (RFC §8) - options.go + api.go + transport.go: WithWebSocket(gin.HandlerFunc) alongside existing WithWSHandler(http.Handler); gin form wins when both supplied (RFC §2.2) - openapi.go: apiSuggestedReplacement + apiDeprecationNoticeURL as reusable header components; NoticeURL on a RouteDescription flips operation deprecated flag and emits response header doc - cmd/api/*.go: migrated from Cobra (cli.NewCommand, StringFlag) to new path-based CLI API (c.Command + core.Options.String/Int/Bool); replaces the 1,422-line Cobra test suite with _Good/_Bad/_Ugly triads on the new surface - webhook_test.go + sunset_test.go + websocket_test.go: full coverage Co-Authored-By: Virgil <virgil@lethean.io>
208 lines
6.4 KiB
Go
208 lines
6.4 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
core "dappco.re/go/core"
|
|
)
|
|
|
|
// WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook
|
|
// payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and
|
|
// a signature header so receivers can validate authenticity, integrity, and
|
|
// reject replays.
|
|
//
|
|
// The signature format mirrors the PHP-side WebhookSignature service:
|
|
//
|
|
// signature = HMAC-SHA256(timestamp + "." + payload, secret)
|
|
//
|
|
// signer := api.NewWebhookSigner("supersecret")
|
|
// headers := signer.Headers([]byte(`{"event":"workspace.created"}`))
|
|
// // headers["X-Webhook-Signature"] is the hex digest
|
|
// // headers["X-Webhook-Timestamp"] is the Unix timestamp string
|
|
type WebhookSigner struct {
|
|
secret []byte
|
|
tolerance time.Duration
|
|
}
|
|
|
|
const (
|
|
// WebhookSignatureHeader is the response/request header that carries the
|
|
// HMAC-SHA256 hex digest of a signed webhook payload.
|
|
WebhookSignatureHeader = "X-Webhook-Signature"
|
|
|
|
// WebhookTimestampHeader is the response/request header that carries the
|
|
// Unix timestamp used to derive the signature.
|
|
WebhookTimestampHeader = "X-Webhook-Timestamp"
|
|
|
|
// DefaultWebhookTolerance is the maximum age of a webhook timestamp the
|
|
// signer will accept. Five minutes mirrors the PHP-side default and
|
|
// allows for reasonable clock skew between sender and receiver.
|
|
DefaultWebhookTolerance = 5 * time.Minute
|
|
)
|
|
|
|
// NewWebhookSigner constructs a signer that uses the given shared secret with
|
|
// the default timestamp tolerance (five minutes).
|
|
//
|
|
// signer := api.NewWebhookSigner("shared-secret")
|
|
func NewWebhookSigner(secret string) *WebhookSigner {
|
|
return &WebhookSigner{
|
|
secret: []byte(secret),
|
|
tolerance: DefaultWebhookTolerance,
|
|
}
|
|
}
|
|
|
|
// NewWebhookSignerWithTolerance constructs a signer with a custom timestamp
|
|
// tolerance. A tolerance of zero or negative falls back to
|
|
// DefaultWebhookTolerance to avoid silently disabling replay protection.
|
|
//
|
|
// signer := api.NewWebhookSignerWithTolerance("secret", 30*time.Second)
|
|
func NewWebhookSignerWithTolerance(secret string, tolerance time.Duration) *WebhookSigner {
|
|
if tolerance <= 0 {
|
|
tolerance = DefaultWebhookTolerance
|
|
}
|
|
return &WebhookSigner{
|
|
secret: []byte(secret),
|
|
tolerance: tolerance,
|
|
}
|
|
}
|
|
|
|
// GenerateWebhookSecret returns a hex-encoded 32-byte random string suitable
|
|
// for use as a webhook signing secret. Output length is 64 characters.
|
|
//
|
|
// secret, err := api.GenerateWebhookSecret()
|
|
// // secret = "9f1a..." (64 hex chars)
|
|
func GenerateWebhookSecret() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", core.E("WebhookSigner.GenerateSecret", "read random bytes", err)
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// Tolerance returns the configured maximum age tolerance for verification.
|
|
//
|
|
// d := signer.Tolerance()
|
|
func (s *WebhookSigner) Tolerance() time.Duration {
|
|
if s == nil || s.tolerance <= 0 {
|
|
return DefaultWebhookTolerance
|
|
}
|
|
return s.tolerance
|
|
}
|
|
|
|
// Sign returns the hex-encoded HMAC-SHA256 of "timestamp.payload" using the
|
|
// signer's secret. Callers may pass any non-nil payload bytes (typically a
|
|
// JSON-encoded event body).
|
|
//
|
|
// digest := signer.Sign(payload, time.Now().Unix())
|
|
func (s *WebhookSigner) Sign(payload []byte, timestamp int64) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
mac.Write([]byte(strconv.FormatInt(timestamp, 10)))
|
|
mac.Write([]byte("."))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// SignNow signs the payload using the current Unix timestamp and returns
|
|
// both the signature and the timestamp used.
|
|
//
|
|
// sig, ts := signer.SignNow(payload)
|
|
func (s *WebhookSigner) SignNow(payload []byte) (string, int64) {
|
|
now := time.Now().Unix()
|
|
return s.Sign(payload, now), now
|
|
}
|
|
|
|
// Headers returns the HTTP headers a sender should attach to a webhook
|
|
// request: X-Webhook-Signature and X-Webhook-Timestamp. The current Unix
|
|
// timestamp is used.
|
|
//
|
|
// for k, v := range signer.Headers(payload) {
|
|
// req.Header.Set(k, v)
|
|
// }
|
|
func (s *WebhookSigner) Headers(payload []byte) map[string]string {
|
|
sig, ts := s.SignNow(payload)
|
|
return map[string]string{
|
|
WebhookSignatureHeader: sig,
|
|
WebhookTimestampHeader: strconv.FormatInt(ts, 10),
|
|
}
|
|
}
|
|
|
|
// Verify reports whether the given signature matches the payload and the
|
|
// timestamp is within the signer's tolerance window. Comparison uses
|
|
// hmac.Equal to avoid timing attacks.
|
|
//
|
|
// if signer.Verify(payload, sig, ts) {
|
|
// // accept event
|
|
// }
|
|
func (s *WebhookSigner) Verify(payload []byte, signature string, timestamp int64) bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
if !s.IsTimestampValid(timestamp) {
|
|
return false
|
|
}
|
|
expected := s.Sign(payload, timestamp)
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|
|
|
|
// VerifySignatureOnly compares the signature without checking timestamp
|
|
// freshness. Use it when timestamp validation is performed elsewhere or in
|
|
// tests where a stable timestamp is required.
|
|
//
|
|
// ok := signer.VerifySignatureOnly(payload, sig, fixedTimestamp)
|
|
func (s *WebhookSigner) VerifySignatureOnly(payload []byte, signature string, timestamp int64) bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
expected := s.Sign(payload, timestamp)
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|
|
|
|
// IsTimestampValid reports whether the given Unix timestamp is within the
|
|
// signer's configured tolerance window relative to the current time.
|
|
//
|
|
// if !signer.IsTimestampValid(ts) {
|
|
// return errors.New("webhook timestamp expired")
|
|
// }
|
|
func (s *WebhookSigner) IsTimestampValid(timestamp int64) bool {
|
|
tol := s.Tolerance()
|
|
now := time.Now().Unix()
|
|
delta := now - timestamp
|
|
if delta < 0 {
|
|
delta = -delta
|
|
}
|
|
return time.Duration(delta)*time.Second <= tol
|
|
}
|
|
|
|
// VerifyRequest extracts the signature and timestamp headers from a request
|
|
// and verifies the given payload against them. Returns false when the
|
|
// headers are missing, malformed, or the signature does not match.
|
|
//
|
|
// if !signer.VerifyRequest(r, payload) {
|
|
// http.Error(w, "invalid signature", http.StatusUnauthorized)
|
|
// return
|
|
// }
|
|
func (s *WebhookSigner) VerifyRequest(r *http.Request, payload []byte) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
sig := r.Header.Get(WebhookSignatureHeader)
|
|
rawTS := r.Header.Get(WebhookTimestampHeader)
|
|
if sig == "" || rawTS == "" {
|
|
return false
|
|
}
|
|
ts, err := strconv.ParseInt(rawTS, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return s.Verify(payload, sig, ts)
|
|
}
|