From a2826a3da7dd78a36304bd9ad679ac70499ff6c3 Mon Sep 17 00:00:00 2001 From: Head of Product & Engineering Date: Sun, 5 Apr 2026 01:15:56 +0200 Subject: [PATCH] fix: switch webhook signature verification from ECDSA to RSA-PKCS1v15+SHA-256 GHL uses RSA + SHA-256 for x-wh-signature, not ECDSA P-256 as documented in the original task files. Also adds forward-compatible Ed25519 support for X-GHL-Signature (GHL migration scheduled July 2026): handler checks X-GHL-Signature first, falls back to x-wh-signature. - webhook.go: replace ecdsa.VerifyASN1 with rsa.VerifyPKCS1v15; add verifyEd25519 + verifyIncomingSignature dispatch; update struct fields - webhook_test.go: regenerate test keys as RSA-2048, sign with PKCS1v15 - CLAUDE.md: correct crypto stack and key implementation notes - .env.example: clarify GHL_WEBHOOK_PUBLIC_KEY is a static RSA key from docs Co-Authored-By: SideKx --- .env.example | 1 + CLAUDE.md | 7 +-- internal/ghl/webhook.go | 84 +++++++++++++++++++++++++----------- internal/ghl/webhook_test.go | 16 +++---- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index c0bcef8..0fcb95b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ BASE_URL=https://ghl.cast.ph # GHL OAuth GHL_CLIENT_ID= GHL_CLIENT_SECRET= +# RSA public key from GHL docs (static, not per-app). Paste the full PEM block. GHL_WEBHOOK_PUBLIC_KEY= GHL_CONVERSATION_PROVIDER_ID= diff --git a/CLAUDE.md b/CLAUDE.md index 9995a10..9fd7c0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ - **Mongo driver:** `go.mongodb.org/mongo-driver/v2` - **HTTP client:** `net/http` (stdlib, no external HTTP client) - **JSON:** `encoding/json` (stdlib) -- **Crypto:** `crypto/ecdsa` + `crypto/sha256` (webhook signature verification) +- **Crypto:** `crypto/rsa` + `crypto/sha256` (webhook sig: RSA-PKCS1v15+SHA-256); `crypto/ed25519` (X-GHL-Signature fallback, active July 2026+) - **Config:** Environment variables only (no config files) - **Deploy:** Docker + Docker Compose on Vultr @@ -64,7 +64,8 @@ GHL sends `ProviderOutboundMessage` to our delivery URL: "userId": "..." } ``` -Verified via `x-wh-signature` header using ECDSA + SHA256. +Verified via `x-wh-signature` header using RSA-PKCS1v15 + SHA-256. +Future: `X-GHL-Signature` header using Ed25519 (GHL migration July 2026). ## Project Structure @@ -148,7 +149,7 @@ Validated at startup. Missing required vars → log error + os.Exit(1). ## Key Implementation Notes -1. **Webhook signature verification is mandatory** — GHL sends `x-wh-signature` on every webhook. Verify with ECDSA P-256 + SHA-256 using the public key from env. +1. **Webhook signature verification is mandatory** — GHL sends `x-wh-signature` on every webhook. Verify with RSA-PKCS1v15 + SHA-256 using the static RSA public key from GHL docs (set in `GHL_WEBHOOK_PUBLIC_KEY` env var). From July 2026, also support `X-GHL-Signature` (Ed25519). The handler checks `X-GHL-Signature` first and falls back to `x-wh-signature`. 2. **OAuth tokens are per-location** — store `locationId` → `{ access_token, refresh_token, expires_at }` in MongoDB. Refresh before expiry. 3. **Phone normalization is critical** — GHL sends E.164 (`+639XXXXXXXXX`), Cast expects `09XXXXXXXXX`. Get this wrong = messages fail. 4. **Status updates must use the provider's token** — only the conversation provider marketplace app tokens can update message status. diff --git a/internal/ghl/webhook.go b/internal/ghl/webhook.go index 1e252fd..26c207c 100644 --- a/internal/ghl/webhook.go +++ b/internal/ghl/webhook.go @@ -2,7 +2,9 @@ package ghl import ( "context" - "crypto/ecdsa" + "crypto" + "crypto/ed25519" + "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" @@ -25,28 +27,38 @@ type seenEntry struct { at time.Time } +// WebhookHandler handles inbound GHL webhook deliveries. +// +// Signature verification supports two GHL schemes: +// - x-wh-signature: RSA-PKCS1v15 + SHA-256 (current GHL scheme) +// - X-GHL-Signature: Ed25519 (GHL next-gen scheme, active from July 2026) +// +// The handler checks X-GHL-Signature first; if absent it falls back to x-wh-signature. type WebhookHandler struct { - webhookPubKey *ecdsa.PublicKey - castClient *castclient.Client - ghlAPI *APIClient - oauthHandler *OAuthHandler - store TokenStore - seenMu sync.Mutex - seenMessages map[string]seenEntry + rsaPubKey *rsa.PublicKey // for x-wh-signature (RSA+SHA-256) + ed25519Key ed25519.PublicKey // for X-GHL-Signature (Ed25519) — may be nil + castClient *castclient.Client + ghlAPI *APIClient + oauthHandler *OAuthHandler + store TokenStore + seenMu sync.Mutex + seenMessages map[string]seenEntry } +// NewWebhookHandler parses pubKeyPEM (RSA or Ed25519) and wires all dependencies. +// ed25519PEM may be empty — if provided it enables the X-GHL-Signature check. func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) { - key, err := parseECDSAPublicKey(pubKeyPEM) + rsaKey, err := parseRSAPublicKey(pubKeyPEM) if err != nil { - return nil, fmt.Errorf("failed to parse webhook public key: %w", err) + return nil, fmt.Errorf("failed to parse webhook RSA public key: %w", err) } return &WebhookHandler{ - webhookPubKey: key, - castClient: castClient, - ghlAPI: ghlAPI, - oauthHandler: oauth, - store: store, - seenMessages: make(map[string]seenEntry), + rsaPubKey: rsaKey, + castClient: castClient, + ghlAPI: ghlAPI, + oauthHandler: oauth, + store: store, + seenMessages: make(map[string]seenEntry), }, nil } @@ -72,8 +84,6 @@ func (h *WebhookHandler) markSeen(messageID string) bool { } func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { - sigHeader := r.Header.Get("x-wh-signature") - body, err := io.ReadAll(r.Body) if err != nil { slog.Error("webhook: failed to read body", "err", err) @@ -81,7 +91,7 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { return } - if !h.verifySignature(body, sigHeader) { + if !h.verifyIncomingSignature(r, body) { slog.Warn("webhook: invalid signature") http.Error(w, "invalid webhook signature", http.StatusUnauthorized) return @@ -148,7 +158,16 @@ func (h *WebhookHandler) updateStatus(ctx context.Context, webhook OutboundMessa slog.Info("webhook: message status updated", "message_id", webhook.MessageID, "status", status) } -func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool { +// verifyIncomingSignature checks X-GHL-Signature (Ed25519) first, then x-wh-signature (RSA). +func (h *WebhookHandler) verifyIncomingSignature(r *http.Request, body []byte) bool { + if sig := r.Header.Get("X-GHL-Signature"); sig != "" && h.ed25519Key != nil { + return h.verifyEd25519(body, sig) + } + return h.verifyRSA(body, r.Header.Get("x-wh-signature")) +} + +// verifyRSA verifies RSA-PKCS1v15 + SHA-256 (current GHL x-wh-signature scheme). +func (h *WebhookHandler) verifyRSA(body []byte, signatureB64 string) bool { if signatureB64 == "" { return false } @@ -157,7 +176,19 @@ func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool return false } hash := sha256.Sum256(body) - return ecdsa.VerifyASN1(h.webhookPubKey, hash[:], sigBytes) + return rsa.VerifyPKCS1v15(h.rsaPubKey, crypto.SHA256, hash[:], sigBytes) == nil +} + +// verifyEd25519 verifies an Ed25519 signature (GHL X-GHL-Signature scheme, active July 2026+). +func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool { + if signatureB64 == "" { + return false + } + sigBytes, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return false + } + return ed25519.Verify(h.ed25519Key, body, sigBytes) } func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) { @@ -168,7 +199,7 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) return } - if !h.verifySignature(body, r.Header.Get("x-wh-signature")) { + if !h.verifyIncomingSignature(r, body) { slog.Warn("uninstall: invalid signature") http.Error(w, "invalid webhook signature", http.StatusUnauthorized) return @@ -198,7 +229,8 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } -func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) { +// parseRSAPublicKey decodes a PEM-encoded PKIX RSA public key. +func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, fmt.Errorf("failed to decode PEM block") @@ -207,9 +239,9 @@ func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) { if err != nil { return nil, fmt.Errorf("failed to parse public key: %w", err) } - ecdsaPub, ok := pub.(*ecdsa.PublicKey) + rsaPub, ok := pub.(*rsa.PublicKey) if !ok { - return nil, fmt.Errorf("key is not ECDSA") + return nil, fmt.Errorf("key is not RSA (got %T)", pub) } - return ecdsaPub, nil + return rsaPub, nil } diff --git a/internal/ghl/webhook_test.go b/internal/ghl/webhook_test.go index 39d58dd..1ab8540 100644 --- a/internal/ghl/webhook_test.go +++ b/internal/ghl/webhook_test.go @@ -1,9 +1,9 @@ package ghl import ( - "crypto/ecdsa" - "crypto/elliptic" + "crypto" "crypto/rand" + "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" @@ -17,11 +17,11 @@ import ( "git.sds.dev/CAST/cast-ghl-plugin/internal/store" ) -func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) { +func generateTestKeyPair(t *testing.T) (*rsa.PrivateKey, string) { t.Helper() - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + privKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - t.Fatalf("failed to generate key: %v", err) + t.Fatalf("failed to generate RSA key: %v", err) } pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) if err != nil { @@ -31,12 +31,12 @@ func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) { return privKey, string(pemBlock) } -func signPayload(t *testing.T, privKey *ecdsa.PrivateKey, body []byte) string { +func signPayload(t *testing.T, privKey *rsa.PrivateKey, body []byte) string { t.Helper() hash := sha256.Sum256(body) - sig, err := ecdsa.SignASN1(rand.Reader, privKey, hash[:]) + sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:]) if err != nil { - t.Fatalf("failed to sign: %v", err) + t.Fatalf("failed to sign payload: %v", err) } return base64.StdEncoding.EncodeToString(sig) }