// Package integradores verifica webhooks de FCM (JWS compacto) y firma pedidos (RFC 9421). // // Verificá ANTES de parsear el cuerpo: clave por kid con el alg del JWKS, firma, iat/exp (5 minutos con 30 s de // margen), body_sha256 sobre los bytes crudos, aud igual a tu entity_id y jti no visto (anti-replay, OBLIGATORIO). // Los claims desconocidos se ignoran: FCM puede sumar claims sin aviso de versión. package integradores import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "math/big" "strings" "sync" "time" ) const ( clockSkewSeconds = 30 maxAgeSeconds = 300 replayTTL = 10 * time.Minute ) type JWK struct { Kty string `json:"kty"` Crv string `json:"crv"` X string `json:"x"` Y string `json:"y,omitempty"` Kid string `json:"kid"` Alg string `json:"alg"` Use string `json:"use"` } type WebhookClaims struct { Iss string `json:"iss"` Aud string `json:"aud"` Iat int64 `json:"iat"` Exp int64 `json:"exp"` JTI string `json:"jti"` Event string `json:"evt"` BodySHA256 string `json:"body_sha256"` } // ReplayGuard es el anti-replay del jti: CheckAndStore devuelve false si ya lo vio en los últimos 10 minutos; si no, // lo guarda. En producción usá un almacén compartido y persistente, y guardá el jti en la misma transacción que el // efecto de negocio. type ReplayGuard interface { CheckAndStore(jti string, now time.Time) bool } // MemoryReplayGuard es seguro con varias goroutines (un servidor net/http atiende en paralelo). Sólo para empezar: // se pierde al reiniciar. type MemoryReplayGuard struct { mu sync.Mutex seen map[string]time.Time } func NewMemoryReplayGuard() *MemoryReplayGuard { return &MemoryReplayGuard{seen: map[string]time.Time{}} } func (g *MemoryReplayGuard) CheckAndStore(jti string, now time.Time) bool { g.mu.Lock() defer g.mu.Unlock() for id, expires := range g.seen { if !expires.After(now) { delete(g.seen, id) } } if _, exists := g.seen[jti]; exists { return false } g.seen[jti] = now.Add(replayTTL) return true } // VerifyOptions agrupa lo que hace falta para verificar. ReplayGuard es obligatorio. type VerifyOptions struct { Token string RawBody []byte JWKS []JWK EntityID string ReplayGuard ReplayGuard Issuer string // por defecto https://api.fcmpayments.com EventID string // FCM-Event-Id, si lo querés atar al token DeliveryID string // FCM-Delivery-Id, si lo querés atar al token Now time.Time } func decodeURL(value string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(value) } func publicKey(jwk JWK) (any, error) { x, err := decodeURL(jwk.X) if err != nil { return nil, fmt.Errorf("decode JWK x: %w", err) } if jwk.Alg == "EdDSA" && jwk.Kty == "OKP" && jwk.Crv == "Ed25519" { if len(x) != ed25519.PublicKeySize { return nil, fmt.Errorf("invalid Ed25519 key length") } return ed25519.PublicKey(x), nil } if jwk.Alg == "ES256" && jwk.Kty == "EC" && jwk.Crv == "P-256" { y, err := decodeURL(jwk.Y) if err != nil { return nil, fmt.Errorf("decode JWK y: %w", err) } return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil } return nil, fmt.Errorf("unsupported or malformed webhook JWK") } // VerifyFcmWebhook verifica el token y el cuerpo crudo. Parseá el JSON sólo si devuelve nil. func VerifyFcmWebhook(options VerifyOptions) (WebhookClaims, error) { if options.ReplayGuard == nil { return WebhookClaims{}, fmt.Errorf("a ReplayGuard is required") } parts := strings.Split(options.Token, ".") if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { return WebhookClaims{}, fmt.Errorf("malformed JWS") } var header struct { Alg string `json:"alg"` Kid string `json:"kid"` } headerBytes, err := decodeURL(parts[0]) if err != nil || json.Unmarshal(headerBytes, &header) != nil { return WebhookClaims{}, fmt.Errorf("malformed JWS header") } if header.Alg != "EdDSA" && header.Alg != "ES256" { return WebhookClaims{}, fmt.Errorf("unsupported webhook algorithm") } var jwk *JWK for index := range options.JWKS { if options.JWKS[index].Kid == header.Kid { jwk = &options.JWKS[index] break } } if jwk == nil || jwk.Alg != header.Alg || jwk.Use != "sig" { return WebhookClaims{}, fmt.Errorf("unknown or mismatched webhook key") } signature, err := decodeURL(parts[2]) if err != nil { return WebhookClaims{}, fmt.Errorf("malformed JWS signature") } key, err := publicKey(*jwk) if err != nil { return WebhookClaims{}, err } signed := []byte(parts[0] + "." + parts[1]) valid := false if header.Alg == "EdDSA" { edKey, ok := key.(ed25519.PublicKey) valid = ok && len(signature) == ed25519.SignatureSize && ed25519.Verify(edKey, signed, signature) } else { ecKey, ok := key.(*ecdsa.PublicKey) if !ok || len(signature) != 64 { return WebhookClaims{}, fmt.Errorf("ES256 JWS signature must be 64 bytes") } digest := sha256.Sum256(signed) valid = ecdsa.Verify(ecKey, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) } if !valid { return WebhookClaims{}, fmt.Errorf("invalid webhook signature") } // json.Unmarshal ignora claims desconocidos: un claim nuevo no rompe la verificación. var claims WebhookClaims claimBytes, err := decodeURL(parts[1]) if err != nil || json.Unmarshal(claimBytes, &claims) != nil || claims.JTI == "" || claims.Event == "" { return WebhookClaims{}, fmt.Errorf("malformed JWS claims") } now := options.Now if now.IsZero() { now = time.Now() } nowUnix := now.Unix() if claims.Iat > nowUnix+clockSkewSeconds || claims.Iat < nowUnix-maxAgeSeconds-clockSkewSeconds || claims.Exp <= nowUnix-clockSkewSeconds || claims.Exp-claims.Iat != maxAgeSeconds { return WebhookClaims{}, fmt.Errorf("expired or not-yet-valid webhook") } digest := sha256.Sum256(options.RawBody) if subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(digest[:])), []byte(claims.BodySHA256)) != 1 { return WebhookClaims{}, fmt.Errorf("webhook body hash mismatch") } issuer := options.Issuer if issuer == "" { issuer = "https://api.fcmpayments.com" } if claims.Iss != issuer || claims.Aud != options.EntityID { return WebhookClaims{}, fmt.Errorf("invalid webhook issuer or audience") } if options.EventID != "" && claims.Event != options.EventID { return WebhookClaims{}, fmt.Errorf("webhook event id mismatch") } if options.DeliveryID != "" && claims.JTI != options.DeliveryID { return WebhookClaims{}, fmt.Errorf("webhook delivery id mismatch") } // Último: un token inválido nunca consume el jti de una entrega legítima. if !options.ReplayGuard.CheckAndStore(claims.JTI, now) { return WebhookClaims{}, fmt.Errorf("webhook replay") } return claims, nil }