diff --git a/cmd/server/main.go b/cmd/server/main.go index 71ccb63..29d7997 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 1666d35..6cdc66b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/ghl/webhook.go b/internal/ghl/webhook.go index 204dd85..c9c59b9 100644 --- a/internal/ghl/webhook.go +++ b/internal/ghl/webhook.go @@ -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)) diff --git a/internal/ghl/webhook_test.go b/internal/ghl/webhook_test.go index 66b0b79..bd36047 100644 --- a/internal/ghl/webhook_test.go +++ b/internal/ghl/webhook_test.go @@ -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) }