fix: correct webhook signature verification to RSA-PKCS1v15+SHA-256
GHL's x-wh-signature uses RSA-PKCS1v15 + SHA-256, not Ed25519. The previous implementation parsed the wrong key type and would reject all incoming webhooks in production. Changes: - webhook.go: switch x-wh-signature verification to RSA-PKCS1v15+SHA-256 - webhook.go: add optional Ed25519 path for X-GHL-Signature (July 2026+) - config.go: add optional GHL_WEBHOOK_ED25519_KEY for future migration - main.go: pass ed25519 key to NewWebhookHandler - webhook_test.go: update test helpers to use RSA keys Co-Authored-By: SideKx <sidekx.ai@sds.dev>
This commit is contained in:
parent
221cd94999
commit
a3b14f8a2a
@ -54,7 +54,7 @@ func run() error {
|
||||
ghlAPI := ghl.NewAPIClient()
|
||||
oauthHandler := ghl.NewOAuthHandler(cfg.GHLClientID, cfg.GHLClientSecret, cfg.BaseURL, cfg.GHLConversationProviderID, s)
|
||||
|
||||
webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, castClient, ghlAPI, oauthHandler, s)
|
||||
webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, cfg.GHLWebhookEd25519Key, castClient, ghlAPI, oauthHandler, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook handler: %w", err)
|
||||
}
|
||||
|
||||
@ -14,7 +14,10 @@ type Config struct {
|
||||
GHLClientSecret string
|
||||
GHLWebhookPublicKey string
|
||||
GHLConversationProviderID string
|
||||
CastAPIKey string
|
||||
// GHLWebhookEd25519Key is the PEM-encoded Ed25519 public key for X-GHL-Signature verification.
|
||||
// Optional until GHL activates this header (expected July 2026).
|
||||
GHLWebhookEd25519Key string
|
||||
CastAPIKey string
|
||||
CastAPIURL string
|
||||
CastSenderID string
|
||||
MongoURI string
|
||||
@ -33,6 +36,7 @@ func Load() (*Config, error) {
|
||||
GHLClientSecret: os.Getenv("GHL_CLIENT_SECRET"),
|
||||
GHLWebhookPublicKey: os.Getenv("GHL_WEBHOOK_PUBLIC_KEY"),
|
||||
GHLConversationProviderID: os.Getenv("GHL_CONVERSATION_PROVIDER_ID"),
|
||||
GHLWebhookEd25519Key: os.Getenv("GHL_WEBHOOK_ED25519_KEY"),
|
||||
CastAPIKey: os.Getenv("CAST_API_KEY"),
|
||||
CastAPIURL: getEnvDefault("CAST_API_URL", "https://api.cast.ph"),
|
||||
CastSenderID: os.Getenv("CAST_SENDER_ID"),
|
||||
|
||||
@ -2,7 +2,10 @@ package ghl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@ -28,13 +31,14 @@ type seenEntry struct {
|
||||
// WebhookHandler handles inbound GHL webhook deliveries.
|
||||
//
|
||||
// Signature verification supports two GHL schemes:
|
||||
// - x-wh-signature: Ed25519 (current GHL scheme, key from Marketplace app settings)
|
||||
// - X-GHL-Signature: Ed25519 (GHL next-gen header name, active from July 2026)
|
||||
// - x-wh-signature: RSA-PKCS1v15 + SHA-256 (current scheme; static key from GHL docs)
|
||||
// - X-GHL-Signature: Ed25519 (GHL next-gen header, active July 2026; optional key)
|
||||
//
|
||||
// Both headers use the same Ed25519 public key configured via GHL_WEBHOOK_PUBLIC_KEY.
|
||||
// The handler checks X-GHL-Signature first; if absent it falls back to x-wh-signature.
|
||||
// The handler checks X-GHL-Signature first (if an Ed25519 key is configured),
|
||||
// then falls back to x-wh-signature (RSA).
|
||||
type WebhookHandler struct {
|
||||
webhookKey ed25519.PublicKey // Ed25519 key for both x-wh-signature and X-GHL-Signature
|
||||
rsaKey *rsa.PublicKey // RSA key for x-wh-signature (current GHL scheme)
|
||||
ed25519Key ed25519.PublicKey // Ed25519 key for X-GHL-Signature (July 2026+), nil if not configured
|
||||
castClient *castclient.Client
|
||||
ghlAPI *APIClient
|
||||
oauthHandler *OAuthHandler
|
||||
@ -43,14 +47,26 @@ type WebhookHandler struct {
|
||||
seenMessages map[string]seenEntry
|
||||
}
|
||||
|
||||
// NewWebhookHandler parses pubKeyPEM (Ed25519 PKIX) and wires all dependencies.
|
||||
func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
|
||||
key, err := parseEd25519PublicKey(pubKeyPEM)
|
||||
// NewWebhookHandler parses rsaKeyPEM (RSA PKIX) and wires all dependencies.
|
||||
// ed25519KeyPEM is optional (empty string = not configured); provide it when
|
||||
// GHL activates X-GHL-Signature (expected July 2026).
|
||||
func NewWebhookHandler(rsaKeyPEM string, ed25519KeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
|
||||
rsaKey, err := parseRSAPublicKey(rsaKeyPEM)
|
||||
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)
|
||||
}
|
||||
|
||||
var ed25519Key ed25519.PublicKey
|
||||
if ed25519KeyPEM != "" {
|
||||
ed25519Key, err = parseEd25519PublicKey(ed25519KeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse webhook Ed25519 public key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &WebhookHandler{
|
||||
webhookKey: key,
|
||||
rsaKey: rsaKey,
|
||||
ed25519Key: ed25519Key,
|
||||
castClient: castClient,
|
||||
ghlAPI: ghlAPI,
|
||||
oauthHandler: oauth,
|
||||
@ -171,13 +187,26 @@ func (h *WebhookHandler) updateStatus(ctx context.Context, webhook OutboundMessa
|
||||
slog.Info("webhook: message status updated", "message_id", webhook.MessageID, "status", status)
|
||||
}
|
||||
|
||||
// verifyIncomingSignature checks X-GHL-Signature first, then x-wh-signature.
|
||||
// Both use the same Ed25519 key from GHL Marketplace app settings.
|
||||
// verifyIncomingSignature checks X-GHL-Signature (Ed25519) first when configured,
|
||||
// then falls back to x-wh-signature (RSA-PKCS1v15 + SHA-256).
|
||||
func (h *WebhookHandler) verifyIncomingSignature(r *http.Request, body []byte) bool {
|
||||
if sig := r.Header.Get("X-GHL-Signature"); sig != "" {
|
||||
if sig := r.Header.Get("X-GHL-Signature"); sig != "" && h.ed25519Key != nil {
|
||||
return h.verifyEd25519(body, sig)
|
||||
}
|
||||
return h.verifyEd25519(body, r.Header.Get("x-wh-signature"))
|
||||
return h.verifyRSA(body, r.Header.Get("x-wh-signature"))
|
||||
}
|
||||
|
||||
// verifyRSA verifies a base64-encoded RSA-PKCS1v15 + SHA-256 signature over body.
|
||||
func (h *WebhookHandler) verifyRSA(body []byte, signatureB64 string) bool {
|
||||
if signatureB64 == "" {
|
||||
return false
|
||||
}
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hash := sha256.Sum256(body)
|
||||
return rsa.VerifyPKCS1v15(h.rsaKey, crypto.SHA256, hash[:], sigBytes) == nil
|
||||
}
|
||||
|
||||
// verifyEd25519 verifies a raw Ed25519 signature (base64-encoded) over body.
|
||||
@ -189,7 +218,7 @@ func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ed25519.Verify(h.webhookKey, body, sigBytes)
|
||||
return ed25519.Verify(h.ed25519Key, body, sigBytes)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
@ -230,9 +259,28 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// parseEd25519PublicKey decodes a PEM-encoded PKIX Ed25519 public key.
|
||||
// parseRSAPublicKey decodes a PEM-encoded PKIX RSA public key.
|
||||
// Tolerates literal \n sequences in the input (common when stored as a single
|
||||
// line in a .env file).
|
||||
func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
|
||||
pemStr = strings.ReplaceAll(pemStr, `\n`, "\n")
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key is not RSA (got %T)", pub)
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
// parseEd25519PublicKey decodes a PEM-encoded PKIX Ed25519 public key.
|
||||
// Tolerates literal \n sequences in the input.
|
||||
func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) {
|
||||
pemStr = strings.ReplaceAll(pemStr, `\n`, "\n")
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package ghl
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
@ -15,13 +17,13 @@ import (
|
||||
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
||||
)
|
||||
|
||||
func generateTestKeyPair(t *testing.T) (ed25519.PrivateKey, string) {
|
||||
func generateTestKeyPair(t *testing.T) (*rsa.PrivateKey, string) {
|
||||
t.Helper()
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate Ed25519 key: %v", err)
|
||||
t.Fatalf("failed to generate RSA key: %v", err)
|
||||
}
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(pubKey)
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal public key: %v", err)
|
||||
}
|
||||
@ -29,9 +31,13 @@ func generateTestKeyPair(t *testing.T) (ed25519.PrivateKey, string) {
|
||||
return privKey, string(pemBlock)
|
||||
}
|
||||
|
||||
func signPayload(t *testing.T, privKey ed25519.PrivateKey, body []byte) string {
|
||||
func signPayload(t *testing.T, privKey *rsa.PrivateKey, body []byte) string {
|
||||
t.Helper()
|
||||
sig := ed25519.Sign(privKey, body)
|
||||
hash := sha256.Sum256(body)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:])
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
@ -39,7 +45,7 @@ func newTestHandler(t *testing.T, pubPEM string) *WebhookHandler {
|
||||
t.Helper()
|
||||
ms := &inMemStore{}
|
||||
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, "", castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create handler: %v", err)
|
||||
}
|
||||
@ -161,7 +167,7 @@ func TestHandleUninstall_ValidSignature(t *testing.T) {
|
||||
token: &store.TokenRecord{LocationID: "loc-uninstall"},
|
||||
}
|
||||
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, "", castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create handler: %v", err)
|
||||
}
|
||||
@ -188,7 +194,7 @@ func TestHandleUninstall_InvalidSignature(t *testing.T) {
|
||||
_, pubPEM := generateTestKeyPair(t)
|
||||
ms := &inMemStore{}
|
||||
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
handler, err := NewWebhookHandler(pubPEM, "", castclient.NewClient("http://localhost:1", "k", ""), NewAPIClient(), oauth, ms)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create handler: %v", err)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user