diff --git a/internal/ghl/webhook.go b/internal/ghl/webhook.go index 75e06f1..ef2fc7f 100644 --- a/internal/ghl/webhook.go +++ b/internal/ghl/webhook.go @@ -2,10 +2,7 @@ package ghl import ( "context" - "crypto" "crypto/ed25519" - "crypto/rsa" - "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" @@ -31,13 +28,13 @@ type seenEntry struct { // 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) +// - 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) // +// 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. type WebhookHandler struct { - rsaPubKey *rsa.PublicKey // for x-wh-signature (RSA+SHA-256) - ed25519Key ed25519.PublicKey // for X-GHL-Signature (Ed25519) — may be nil + webhookKey ed25519.PublicKey // Ed25519 key for both x-wh-signature and X-GHL-Signature castClient *castclient.Client ghlAPI *APIClient oauthHandler *OAuthHandler @@ -46,15 +43,14 @@ type WebhookHandler struct { 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. +// NewWebhookHandler parses pubKeyPEM (Ed25519 PKIX) and wires all dependencies. func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler, store TokenStore) (*WebhookHandler, error) { - rsaKey, err := parseRSAPublicKey(pubKeyPEM) + key, err := parseEd25519PublicKey(pubKeyPEM) 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) } return &WebhookHandler{ - rsaPubKey: rsaKey, + webhookKey: key, castClient: castClient, ghlAPI: ghlAPI, oauthHandler: oauth, @@ -159,28 +155,16 @@ 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 (Ed25519) first, then x-wh-signature (RSA). +// verifyIncomingSignature checks X-GHL-Signature first, then x-wh-signature. +// Both use the same Ed25519 key from GHL Marketplace app settings. 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.verifyRSA(body, r.Header.Get("x-wh-signature")) + return h.verifyEd25519(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 - } - sigBytes, err := base64.StdEncoding.DecodeString(signatureB64) - if err != nil { - return false - } - hash := sha256.Sum256(body) - return rsa.VerifyPKCS1v15(h.rsaPubKey, crypto.SHA256, hash[:], sigBytes) == nil -} - -// verifyEd25519 verifies an Ed25519 signature (GHL X-GHL-Signature scheme, active July 2026+). +// verifyEd25519 verifies a raw Ed25519 signature (base64-encoded) over body. func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool { if signatureB64 == "" { return false @@ -189,7 +173,7 @@ func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool { if err != nil { 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) { @@ -230,10 +214,10 @@ func (h *WebhookHandler) HandleUninstall(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } -// parseRSAPublicKey decodes a PEM-encoded PKIX RSA public key. -// Tolerates literal \n sequences in the input (common when the key is stored -// as a single line in a .env file). -func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) { +// parseEd25519PublicKey decodes a PEM-encoded PKIX Ed25519 public key. +// Tolerates literal \n sequences in the input (common when stored as a single +// line in a .env file). +func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) { pemStr = strings.ReplaceAll(pemStr, `\n`, "\n") block, _ := pem.Decode([]byte(pemStr)) if block == nil { @@ -243,9 +227,9 @@ func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) { if err != nil { return nil, fmt.Errorf("failed to parse public key: %w", err) } - rsaPub, ok := pub.(*rsa.PublicKey) + ed25519Pub, ok := pub.(ed25519.PublicKey) if !ok { - return nil, fmt.Errorf("key is not RSA (got %T)", pub) + return nil, fmt.Errorf("key is not Ed25519 (got %T)", pub) } - return rsaPub, nil + return ed25519Pub, nil } diff --git a/internal/ghl/webhook_test.go b/internal/ghl/webhook_test.go index 1ab8540..66b0b79 100644 --- a/internal/ghl/webhook_test.go +++ b/internal/ghl/webhook_test.go @@ -1,10 +1,8 @@ package ghl import ( - "crypto" + "crypto/ed25519" "crypto/rand" - "crypto/rsa" - "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" @@ -17,13 +15,13 @@ import ( "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() - privKey, err := rsa.GenerateKey(rand.Reader, 2048) + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) 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 { 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) } -func signPayload(t *testing.T, privKey *rsa.PrivateKey, body []byte) string { +func signPayload(t *testing.T, privKey ed25519.PrivateKey, body []byte) string { t.Helper() - 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) - } + sig := ed25519.Sign(privKey, body) return base64.StdEncoding.EncodeToString(sig) }