package ghl import ( "context" "crypto" "crypto/ed25519" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast" "git.sds.dev/CAST/cast-ghl-plugin/internal/phone" ) const seenMessageTTL = 10 * time.Minute type seenEntry struct { 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 { rsaPubKey *rsa.PublicKey // for x-wh-signature (RSA+SHA-256) ed25519Key ed25519.PublicKey // for X-GHL-Signature (Ed25519) — may be nil castClient *castclient.Client ghlAPI *APIClient oauthHandler *OAuthHandler store TokenStore seenMu sync.Mutex 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) { rsaKey, err := parseRSAPublicKey(pubKeyPEM) if err != nil { return nil, fmt.Errorf("failed to parse webhook RSA public key: %w", err) } return &WebhookHandler{ rsaPubKey: rsaKey, castClient: castClient, ghlAPI: ghlAPI, oauthHandler: oauth, store: store, seenMessages: make(map[string]seenEntry), }, nil } // markSeen returns true if messageID was already seen within seenMessageTTL (duplicate). // Otherwise records it and returns false. func (h *WebhookHandler) markSeen(messageID string) bool { h.seenMu.Lock() defer h.seenMu.Unlock() now := time.Now() // Evict expired entries on every call to avoid unbounded growth. for id, e := range h.seenMessages { if now.Sub(e.at) > seenMessageTTL { delete(h.seenMessages, id) } } if _, exists := h.seenMessages[messageID]; exists { return true } h.seenMessages[messageID] = seenEntry{at: now} return false } func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { slog.Error("webhook: failed to read body", "err", err) http.Error(w, "failed to read request body", http.StatusInternalServerError) return } if !h.verifyIncomingSignature(r, body) { slog.Warn("webhook: invalid signature") http.Error(w, "invalid webhook signature", http.StatusUnauthorized) return } var webhook OutboundMessageWebhook if err := json.Unmarshal(body, &webhook); err != nil { slog.Error("webhook: failed to parse payload", "err", err) http.Error(w, "invalid payload", http.StatusBadRequest) return } if webhook.Type != "SMS" { slog.Debug("webhook: ignoring non-SMS webhook", "type", webhook.Type) w.WriteHeader(http.StatusOK) return } if h.markSeen(webhook.MessageID) { slog.Warn("webhook: duplicate messageId ignored", "message_id", webhook.MessageID) w.WriteHeader(http.StatusOK) return } slog.Info("webhook: received outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID) w.WriteHeader(http.StatusOK) go h.processOutbound(webhook) } func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() localPhone, err := phone.ToLocal(webhook.Phone) if err != nil { slog.Error("webhook: phone normalization failed", "phone", webhook.Phone, "err", err) h.updateStatus(ctx, webhook, "failed") return } _, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message) if err != nil { slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "err", err) h.updateStatus(ctx, webhook, "failed") return } slog.Info("webhook: cast send success", "message_id", webhook.MessageID) h.updateStatus(ctx, webhook, "delivered") } func (h *WebhookHandler) updateStatus(ctx context.Context, webhook OutboundMessageWebhook, status string) { token, err := h.oauthHandler.GetValidToken(ctx, webhook.LocationID) if err != nil { slog.Error("webhook: failed to get valid token for status update", "location_id", webhook.LocationID, "err", err) return } if err := h.ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, status); err != nil { slog.Error("webhook: failed to update message status", "message_id", webhook.MessageID, "status", status, "err", err) return } slog.Info("webhook: message status updated", "message_id", webhook.MessageID, "status", status) } // 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 == "" { 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+). 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) { body, err := io.ReadAll(r.Body) if err != nil { slog.Error("uninstall: failed to read body", "err", err) http.Error(w, "failed to read request body", http.StatusInternalServerError) return } if !h.verifyIncomingSignature(r, body) { slog.Warn("uninstall: invalid signature") http.Error(w, "invalid webhook signature", http.StatusUnauthorized) return } var payload UninstallWebhook if err := json.Unmarshal(body, &payload); err != nil { slog.Error("uninstall: failed to parse payload", "err", err) http.Error(w, "invalid payload", http.StatusBadRequest) return } if payload.LocationID == "" { slog.Error("uninstall: missing locationId") http.Error(w, "missing locationId", http.StatusBadRequest) return } ctx := r.Context() if err := h.store.DeleteToken(ctx, payload.LocationID); err != nil { slog.Error("uninstall: failed to delete token", "location_id", payload.LocationID, "err", err) http.Error(w, "failed to process uninstall", http.StatusInternalServerError) return } slog.Info("uninstall: token deleted", "location_id", payload.LocationID) 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) { 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 }