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 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=
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user