fix: switch webhook signature verification from ECDSA to RSA-PKCS1v15+SHA-256
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
parent
dcf1e3070e
commit
a2826a3da7
@ -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=
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user