Revert "fix: correct webhook signature verification to RSA-PKCS1v15+SHA-256"

This reverts commit a3b14f8a2aae628f81974510b883c311fca5b38d.
This commit is contained in:
Head of Product & Engineering 2026-04-06 19:49:35 +02:00
parent 04b35e4df6
commit 90c3b4de39
4 changed files with 28 additions and 86 deletions

View File

@ -54,7 +54,7 @@ func run() error {
ghlAPI := ghl.NewAPIClient() ghlAPI := ghl.NewAPIClient()
oauthHandler := ghl.NewOAuthHandler(cfg.GHLClientID, cfg.GHLClientSecret, cfg.BaseURL, cfg.GHLConversationProviderID, s) oauthHandler := ghl.NewOAuthHandler(cfg.GHLClientID, cfg.GHLClientSecret, cfg.BaseURL, cfg.GHLConversationProviderID, s)
webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, cfg.GHLWebhookEd25519Key, castClient, ghlAPI, oauthHandler, s) webhookHandler, err := ghl.NewWebhookHandler(cfg.GHLWebhookPublicKey, castClient, ghlAPI, oauthHandler, s)
if err != nil { if err != nil {
return fmt.Errorf("webhook handler: %w", err) return fmt.Errorf("webhook handler: %w", err)
} }

View File

@ -14,9 +14,6 @@ type Config struct {
GHLClientSecret string GHLClientSecret string
GHLWebhookPublicKey string GHLWebhookPublicKey string
GHLConversationProviderID string GHLConversationProviderID 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 CastAPIKey string
CastAPIURL string CastAPIURL string
CastSenderID string CastSenderID string
@ -36,7 +33,6 @@ func Load() (*Config, error) {
GHLClientSecret: os.Getenv("GHL_CLIENT_SECRET"), GHLClientSecret: os.Getenv("GHL_CLIENT_SECRET"),
GHLWebhookPublicKey: os.Getenv("GHL_WEBHOOK_PUBLIC_KEY"), GHLWebhookPublicKey: os.Getenv("GHL_WEBHOOK_PUBLIC_KEY"),
GHLConversationProviderID: os.Getenv("GHL_CONVERSATION_PROVIDER_ID"), GHLConversationProviderID: os.Getenv("GHL_CONVERSATION_PROVIDER_ID"),
GHLWebhookEd25519Key: os.Getenv("GHL_WEBHOOK_ED25519_KEY"),
CastAPIKey: os.Getenv("CAST_API_KEY"), CastAPIKey: os.Getenv("CAST_API_KEY"),
CastAPIURL: getEnvDefault("CAST_API_URL", "https://api.cast.ph"), CastAPIURL: getEnvDefault("CAST_API_URL", "https://api.cast.ph"),
CastSenderID: os.Getenv("CAST_SENDER_ID"), CastSenderID: os.Getenv("CAST_SENDER_ID"),

View File

@ -2,10 +2,7 @@ package ghl
import ( import (
"context" "context"
"crypto"
"crypto/ed25519" "crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@ -31,14 +28,13 @@ type seenEntry struct {
// WebhookHandler handles inbound GHL webhook deliveries. // WebhookHandler handles inbound GHL webhook deliveries.
// //
// Signature verification supports two GHL schemes: // Signature verification supports two GHL schemes:
// - x-wh-signature: RSA-PKCS1v15 + SHA-256 (current scheme; static key from GHL docs) // - x-wh-signature: Ed25519 (current GHL scheme, key from Marketplace app settings)
// - X-GHL-Signature: Ed25519 (GHL next-gen header, active July 2026; optional key) // - X-GHL-Signature: Ed25519 (GHL next-gen header name, active from July 2026)
// //
// The handler checks X-GHL-Signature first (if an Ed25519 key is configured), // Both headers use the same Ed25519 public key configured via GHL_WEBHOOK_PUBLIC_KEY.
// then falls back to x-wh-signature (RSA). // The handler checks X-GHL-Signature first; if absent it falls back to x-wh-signature.
type WebhookHandler struct { type WebhookHandler struct {
rsaKey *rsa.PublicKey // RSA key for x-wh-signature (current GHL scheme) webhookKey ed25519.PublicKey // Ed25519 key for both x-wh-signature and X-GHL-Signature
ed25519Key ed25519.PublicKey // Ed25519 key for X-GHL-Signature (July 2026+), nil if not configured
castClient *castclient.Client castClient *castclient.Client
ghlAPI *APIClient ghlAPI *APIClient
oauthHandler *OAuthHandler oauthHandler *OAuthHandler
@ -47,26 +43,14 @@ type WebhookHandler struct {
seenMessages map[string]seenEntry seenMessages map[string]seenEntry
} }
// NewWebhookHandler parses rsaKeyPEM (RSA PKIX) and wires all dependencies. // NewWebhookHandler parses pubKeyPEM (Ed25519 PKIX) and wires all dependencies.
// ed25519KeyPEM is optional (empty string = not configured); provide it when func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
// GHL activates X-GHL-Signature (expected July 2026). key, err := parseEd25519PublicKey(pubKeyPEM)
func NewWebhookHandler(rsaKeyPEM string, ed25519KeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) {
rsaKey, err := parseRSAPublicKey(rsaKeyPEM)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse webhook RSA public key: %w", err) return nil, fmt.Errorf("failed to parse webhook 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{ return &WebhookHandler{
rsaKey: rsaKey, webhookKey: key,
ed25519Key: ed25519Key,
castClient: castClient, castClient: castClient,
ghlAPI: ghlAPI, ghlAPI: ghlAPI,
oauthHandler: oauth, oauthHandler: oauth,
@ -187,26 +171,13 @@ 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)
} }
// verifyIncomingSignature checks X-GHL-Signature (Ed25519) first when configured, // verifyIncomingSignature checks X-GHL-Signature first, then x-wh-signature.
// then falls back to x-wh-signature (RSA-PKCS1v15 + SHA-256). // Both use the same Ed25519 key from GHL Marketplace app settings.
func (h *WebhookHandler) verifyIncomingSignature(r *http.Request, body []byte) bool { func (h *WebhookHandler) verifyIncomingSignature(r *http.Request, body []byte) bool {
if sig := r.Header.Get("X-GHL-Signature"); sig != "" && h.ed25519Key != nil { if sig := r.Header.Get("X-GHL-Signature"); sig != "" {
return h.verifyEd25519(body, sig) return h.verifyEd25519(body, sig)
} }
return h.verifyRSA(body, r.Header.Get("x-wh-signature")) return h.verifyEd25519(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. // verifyEd25519 verifies a raw Ed25519 signature (base64-encoded) over body.
@ -218,7 +189,7 @@ func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool {
if err != nil { if err != nil {
return false return false
} }
return ed25519.Verify(h.ed25519Key, body, sigBytes) return ed25519.Verify(h.webhookKey, body, sigBytes)
} }
func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) { func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) {
@ -259,28 +230,9 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// parseRSAPublicKey decodes a PEM-encoded PKIX RSA public key. // parseEd25519PublicKey decodes a PEM-encoded PKIX Ed25519 public key.
// Tolerates literal \n sequences in the input (common when stored as a single // Tolerates literal \n sequences in the input (common when stored as a single
// line in a .env file). // 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) { func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) {
pemStr = strings.ReplaceAll(pemStr, `\n`, "\n") pemStr = strings.ReplaceAll(pemStr, `\n`, "\n")
block, _ := pem.Decode([]byte(pemStr)) block, _ := pem.Decode([]byte(pemStr))

View File

@ -1,10 +1,8 @@
package ghl package ghl
import ( import (
"crypto" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/pem" "encoding/pem"
@ -17,13 +15,13 @@ import (
"git.sds.dev/CAST/cast-ghl-plugin/internal/store" "git.sds.dev/CAST/cast-ghl-plugin/internal/store"
) )
func generateTestKeyPair(t *testing.T) (*rsa.PrivateKey, string) { func generateTestKeyPair(t *testing.T) (ed25519.PrivateKey, string) {
t.Helper() t.Helper()
privKey, err := rsa.GenerateKey(rand.Reader, 2048) pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil { if err != nil {
t.Fatalf("failed to generate RSA key: %v", err) t.Fatalf("failed to generate Ed25519 key: %v", err)
} }
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) pubBytes, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil { if err != nil {
t.Fatalf("failed to marshal public key: %v", err) t.Fatalf("failed to marshal public key: %v", err)
} }
@ -31,13 +29,9 @@ func generateTestKeyPair(t *testing.T) (*rsa.PrivateKey, string) {
return privKey, string(pemBlock) return privKey, string(pemBlock)
} }
func signPayload(t *testing.T, privKey *rsa.PrivateKey, body []byte) string { func signPayload(t *testing.T, privKey ed25519.PrivateKey, body []byte) string {
t.Helper() t.Helper()
hash := sha256.Sum256(body) sig := ed25519.Sign(privKey, 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) return base64.StdEncoding.EncodeToString(sig)
} }
@ -45,7 +39,7 @@ func newTestHandler(t *testing.T, pubPEM string) *WebhookHandler {
t.Helper() t.Helper()
ms := &inMemStore{} ms := &inMemStore{}
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms) 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 { if err != nil {
t.Fatalf("failed to create handler: %v", err) t.Fatalf("failed to create handler: %v", err)
} }
@ -167,7 +161,7 @@ func TestHandleUninstall_ValidSignature(t *testing.T) {
token: &store.TokenRecord{LocationID: "loc-uninstall"}, token: &store.TokenRecord{LocationID: "loc-uninstall"},
} }
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms) 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 { if err != nil {
t.Fatalf("failed to create handler: %v", err) t.Fatalf("failed to create handler: %v", err)
} }
@ -194,7 +188,7 @@ func TestHandleUninstall_InvalidSignature(t *testing.T) {
_, pubPEM := generateTestKeyPair(t) _, pubPEM := generateTestKeyPair(t)
ms := &inMemStore{} ms := &inMemStore{}
oauth := NewOAuthHandler("c", "s", "http://x", "p", ms) 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 { if err != nil {
t.Fatalf("failed to create handler: %v", err) t.Fatalf("failed to create handler: %v", err)
} }