Head of Product & Engineering 2e07374681
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: tolerate literal \n in GHL_WEBHOOK_PUBLIC_KEY env var
pem.Decode requires actual newlines. When a PEM key is pasted into a
.env file it is commonly stored as a single line with \n literals.
Normalise these before decoding so both formats work.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-05 18:50:56 +02:00

252 lines
7.7 KiB
Go

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
}