// Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest). // Quien llama es dueño de la clave privada Ed25519 y la mantiene fuera de los logs. Cada pedido lleva un nonce // aleatorio nuevo (si repetís uno, FCM responde signature_replayed). Obligatorios: @method y @target-uri; con // cuerpo, además content-type, content-digest e idempotency-key; con FCM-Version, fcm-version. El borde acepta // también, si los cubrís, date, content-length, @authority, @path y @query (lista cerrada). Authorization (la API // key) va en el pedido pero NO se firma: si la cubrís, FCM responde signature_invalid. package integradores import ( "crypto" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/url" "strconv" "strings" "time" ) type RequestToSign struct { Method string TargetURI string Authorization string PrivateKey ed25519.PrivateKey KeyID string Body []byte ContentType string IdempotencyKey string FCMVersion string ExtraComponents []string // date, content-length, @authority, @path, @query Date string // valor del encabezado Date si se cubre "date" Created int64 // sólo pruebas; 0 = hora actual Nonce string // sólo vectores de prueba; vacío = aleatorio } type SignedRequest struct { Headers map[string]string SignatureBase string } func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) } // NewNonce devuelve 16 bytes aleatorios en base64url (22 caracteres). func NewNonce() (string, error) { buffer := make([]byte, 16) if _, err := rand.Read(buffer); err != nil { return "", fmt.Errorf("generate nonce: %w", err) } return base64.RawURLEncoding.EncodeToString(buffer), nil } func digestHeader(body []byte) string { digest := sha256.Sum256(body) return fmt.Sprintf("sha-256=:%s:", b64(digest[:])) } func quote(value string) string { encoded, err := json.Marshal(value) if err != nil { return `""` } return string(encoded) } func derived(component, targetURI string) (string, error) { parsed, err := url.Parse(targetURI) if err != nil { return "", fmt.Errorf("parse target URI: %w", err) } switch component { case "@authority": return strings.ToLower(parsed.Host), nil case "@path": if parsed.EscapedPath() == "" { return "/", nil } return parsed.EscapedPath(), nil default: if parsed.RawQuery == "" { return "?", nil } return "?" + parsed.RawQuery, nil } } // SignFcmRequest arma la base de firma exacta del RFC 9421. Body es nil en un GET; todo POST con cuerpo lleva // además IdempotencyKey. func SignFcmRequest(request RequestToSign) (SignedRequest, error) { if len(request.PrivateKey) != ed25519.PrivateKeySize { return SignedRequest{}, fmt.Errorf("private key must be an Ed25519 key") } method := strings.ToUpper(request.Method) headers := map[string]string{"Authorization": request.Authorization} values := map[string]string{"@method": method, "@target-uri": request.TargetURI} components := []string{"@method", "@target-uri"} if request.Body != nil { if request.ContentType == "" || request.IdempotencyKey == "" { return SignedRequest{}, fmt.Errorf("body requests require content type and idempotency key") } digest := digestHeader(request.Body) headers["Content-Type"] = request.ContentType headers["Content-Digest"] = digest headers["Idempotency-Key"] = request.IdempotencyKey values["content-type"] = request.ContentType values["content-digest"] = digest values["idempotency-key"] = request.IdempotencyKey components = append(components, "content-type", "content-digest", "idempotency-key") } if request.FCMVersion != "" { headers["FCM-Version"] = request.FCMVersion values["fcm-version"] = request.FCMVersion components = append(components, "fcm-version") } for _, extra := range request.ExtraComponents { switch extra { case "date": if request.Date == "" { return SignedRequest{}, fmt.Errorf("date is required to cover the date component") } headers["Date"] = request.Date values["date"] = request.Date case "content-length": length := strconv.Itoa(len(request.Body)) headers["Content-Length"] = length values["content-length"] = length case "@authority", "@path", "@query": value, err := derived(extra, request.TargetURI) if err != nil { return SignedRequest{}, err } values[extra] = value default: return SignedRequest{}, fmt.Errorf("component %s is not accepted by FCM", extra) } components = append(components, extra) } created := request.Created if created == 0 { created = time.Now().Unix() } nonce := request.Nonce if nonce == "" { generated, err := NewNonce() if err != nil { return SignedRequest{}, err } nonce = generated } quoted := make([]string, len(components)) for i, component := range components { quoted[i] = quote(component) } params := fmt.Sprintf("(%s);created=%d;nonce=%s;keyid=%s;alg=\"ed25519\"", strings.Join(quoted, " "), created, quote(nonce), quote(request.KeyID)) lines := make([]string, 0, len(components)+1) for _, component := range components { lines = append(lines, fmt.Sprintf("%s: %s", quote(component), values[component])) } lines = append(lines, fmt.Sprintf("%s: %s", quote("@signature-params"), params)) signatureBase := strings.Join(lines, "\n") signature, err := request.PrivateKey.Sign(nil, []byte(signatureBase), crypto.Hash(0)) if err != nil { return SignedRequest{}, fmt.Errorf("sign request: %w", err) } headers["Signature-Input"] = "sig1=" + params headers["Signature"] = fmt.Sprintf("sig1=:%s:", b64(signature)) return SignedRequest{Headers: headers, SignatureBase: signatureBase}, nil }