fix: switch webhook signature verification from ECDSA to RSA-PKCS1v15+SHA-256
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

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 <sidekx.ai@sds.dev>
This commit is contained in:
Head of Product & Engineering 2026-04-05 01:15:56 +02:00
parent dcf1e3070e
commit a2826a3da7
4 changed files with 71 additions and 37 deletions

View File

@ -4,6 +4,7 @@ BASE_URL=https://ghl.cast.ph
# GHL OAuth # GHL OAuth
GHL_CLIENT_ID= GHL_CLIENT_ID=
GHL_CLIENT_SECRET= GHL_CLIENT_SECRET=
# RSA public key from GHL docs (static, not per-app). Paste the full PEM block.
GHL_WEBHOOK_PUBLIC_KEY= GHL_WEBHOOK_PUBLIC_KEY=
GHL_CONVERSATION_PROVIDER_ID= GHL_CONVERSATION_PROVIDER_ID=

View File

@ -12,7 +12,7 @@
- **Mongo driver:** `go.mongodb.org/mongo-driver/v2` - **Mongo driver:** `go.mongodb.org/mongo-driver/v2`
- **HTTP client:** `net/http` (stdlib, no external HTTP client) - **HTTP client:** `net/http` (stdlib, no external HTTP client)
- **JSON:** `encoding/json` (stdlib) - **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) - **Config:** Environment variables only (no config files)
- **Deploy:** Docker + Docker Compose on Vultr - **Deploy:** Docker + Docker Compose on Vultr
@ -64,7 +64,8 @@ GHL sends `ProviderOutboundMessage` to our delivery URL:
"userId": "..." "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 ## Project Structure
@ -148,7 +149,7 @@ Validated at startup. Missing required vars → log error + os.Exit(1).
## Key Implementation Notes ## 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. 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. 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. 4. **Status updates must use the provider's token** — only the conversation provider marketplace app tokens can update message status.

View File

@ -2,7 +2,9 @@ package ghl
import ( import (
"context" "context"
"crypto/ecdsa" "crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
@ -25,8 +27,16 @@ type seenEntry struct {
at time.Time 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 { type WebhookHandler struct {
webhookPubKey *ecdsa.PublicKey rsaPubKey *rsa.PublicKey // for x-wh-signature (RSA+SHA-256)
ed25519Key ed25519.PublicKey // for X-GHL-Signature (Ed25519) — may be nil
castClient *castclient.Client castClient *castclient.Client
ghlAPI *APIClient ghlAPI *APIClient
oauthHandler *OAuthHandler oauthHandler *OAuthHandler
@ -35,13 +45,15 @@ type WebhookHandler struct {
seenMessages map[string]seenEntry 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) { 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 { 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{ return &WebhookHandler{
webhookPubKey: key, rsaPubKey: rsaKey,
castClient: castClient, castClient: castClient,
ghlAPI: ghlAPI, ghlAPI: ghlAPI,
oauthHandler: oauth, oauthHandler: oauth,
@ -72,8 +84,6 @@ func (h *WebhookHandler) markSeen(messageID string) bool {
} }
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
sigHeader := r.Header.Get("x-wh-signature")
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
slog.Error("webhook: failed to read body", "err", err) slog.Error("webhook: failed to read body", "err", err)
@ -81,7 +91,7 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
return return
} }
if !h.verifySignature(body, sigHeader) { if !h.verifyIncomingSignature(r, body) {
slog.Warn("webhook: invalid signature") slog.Warn("webhook: invalid signature")
http.Error(w, "invalid webhook signature", http.StatusUnauthorized) http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
return 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) 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 == "" { if signatureB64 == "" {
return false return false
} }
@ -157,7 +176,19 @@ func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool
return false return false
} }
hash := sha256.Sum256(body) 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) { 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 return
} }
if !h.verifySignature(body, r.Header.Get("x-wh-signature")) { if !h.verifyIncomingSignature(r, body) {
slog.Warn("uninstall: invalid signature") slog.Warn("uninstall: invalid signature")
http.Error(w, "invalid webhook signature", http.StatusUnauthorized) http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
return return
@ -198,7 +229,8 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK) 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)) block, _ := pem.Decode([]byte(pemStr))
if block == nil { if block == nil {
return nil, fmt.Errorf("failed to decode PEM block") return nil, fmt.Errorf("failed to decode PEM block")
@ -207,9 +239,9 @@ func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err) return nil, fmt.Errorf("failed to parse public key: %w", err)
} }
ecdsaPub, ok := pub.(*ecdsa.PublicKey) rsaPub, ok := pub.(*rsa.PublicKey)
if !ok { 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
} }

View File

@ -1,9 +1,9 @@
package ghl package ghl
import ( import (
"crypto/ecdsa" "crypto"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
@ -17,11 +17,11 @@ import (
"git.sds.dev/CAST/cast-ghl-plugin/internal/store" "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() t.Helper()
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { 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) pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil { if err != nil {
@ -31,12 +31,12 @@ func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) {
return privKey, string(pemBlock) 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() t.Helper()
hash := sha256.Sum256(body) 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 { if err != nil {
t.Fatalf("failed to sign: %v", err) t.Fatalf("failed to sign payload: %v", err)
} }
return base64.StdEncoding.EncodeToString(sig) return base64.StdEncoding.EncodeToString(sig)
} }