fix: use Ed25519 for webhook signature verification (x-wh-signature)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
GHL signs x-wh-signature webhooks with an Ed25519 key from the Marketplace app settings, not RSA. The previous RSA implementation caused all webhook signature checks to fail, blocking every outbound SMS send. Changes: - Replace parseRSAPublicKey + RSA verification with parseEd25519PublicKey + ed25519.Verify for x-wh-signature - Both x-wh-signature (current) and X-GHL-Signature (July 2026) now use the same Ed25519 key from GHL_WEBHOOK_PUBLIC_KEY - Remove unused crypto/rsa, crypto/sha256, crypto imports - Update webhook_test.go to generate/sign with Ed25519 instead of RSA Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ad2682c55d
commit
ffb27acc11
@ -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,13 +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 GHL scheme)
|
// - x-wh-signature: Ed25519 (current GHL scheme, key from Marketplace app settings)
|
||||||
// - X-GHL-Signature: Ed25519 (GHL next-gen scheme, active from July 2026)
|
// - 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.
|
// The handler checks X-GHL-Signature first; if absent it falls back to x-wh-signature.
|
||||||
type WebhookHandler struct {
|
type WebhookHandler struct {
|
||||||
rsaPubKey *rsa.PublicKey // for x-wh-signature (RSA+SHA-256)
|
webhookKey ed25519.PublicKey // Ed25519 key for both x-wh-signature and X-GHL-Signature
|
||||||
ed25519Key ed25519.PublicKey // for X-GHL-Signature (Ed25519) — may be nil
|
|
||||||
castClient *castclient.Client
|
castClient *castclient.Client
|
||||||
ghlAPI *APIClient
|
ghlAPI *APIClient
|
||||||
oauthHandler *OAuthHandler
|
oauthHandler *OAuthHandler
|
||||||
@ -46,15 +43,14 @@ type WebhookHandler struct {
|
|||||||
seenMessages map[string]seenEntry
|
seenMessages map[string]seenEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebhookHandler parses pubKeyPEM (RSA or Ed25519) and wires all dependencies.
|
// NewWebhookHandler parses pubKeyPEM (Ed25519 PKIX) 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) {
|
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 {
|
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{
|
return &WebhookHandler{
|
||||||
rsaPubKey: rsaKey,
|
webhookKey: key,
|
||||||
castClient: castClient,
|
castClient: castClient,
|
||||||
ghlAPI: ghlAPI,
|
ghlAPI: ghlAPI,
|
||||||
oauthHandler: oauth,
|
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)
|
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 {
|
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 RSA-PKCS1v15 + SHA-256 (current GHL x-wh-signature scheme).
|
// verifyEd25519 verifies a raw Ed25519 signature (base64-encoded) 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.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 {
|
func (h *WebhookHandler) verifyEd25519(body []byte, signatureB64 string) bool {
|
||||||
if signatureB64 == "" {
|
if signatureB64 == "" {
|
||||||
return false
|
return false
|
||||||
@ -189,7 +173,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) {
|
||||||
@ -230,10 +214,10 @@ 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 the key is stored
|
// Tolerates literal \n sequences in the input (common when stored as a single
|
||||||
// as a single line in a .env file).
|
// line in a .env file).
|
||||||
func parseRSAPublicKey(pemStr string) (*rsa.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))
|
||||||
if block == nil {
|
if block == nil {
|
||||||
@ -243,9 +227,9 @@ func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||||
}
|
}
|
||||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
ed25519Pub, ok := pub.(ed25519.PublicKey)
|
||||||
if !ok {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user