package services import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) // Cliente de Rapyd (wallets). // // Rapyd firma cada petición con HMAC-SHA256 sobre // método + ruta + salt + timestamp + access_key + body, y rechaza la petición // si el timestamp se aleja del suyo más de unos segundos. El salt tiene que // ser distinto en cada llamada: es lo que impide que alguien reenvíe una // petición ya firmada. // // Las credenciales salen del entorno. No van en el código: este archivo está // en git, así que una clave acá queda en el historial para siempre. const rapydURLPorDefecto = "https://sandboxapi.rapyd.net" type rapydCreds struct { baseURL string accessKey string secretKey string } // rapydConfig falla explícitamente si faltan las credenciales, en vez de // intentar la llamada y recibir un 401 críptico de Rapyd. func rapydConfig() (*rapydCreds, error) { access := strings.TrimSpace(os.Getenv("RAPYD_ACCESS_KEY")) secret := strings.TrimSpace(os.Getenv("RAPYD_SECRET_KEY")) if access == "" || secret == "" { return nil, fmt.Errorf("Rapyd no está configurado: definí RAPYD_ACCESS_KEY y RAPYD_SECRET_KEY") } base := strings.TrimSpace(os.Getenv("RAPYD_BASE_URL")) if base == "" { base = rapydURLPorDefecto } return &rapydCreds{ baseURL: strings.TrimRight(base, "/"), accessKey: access, secretKey: secret, }, nil } // generarSalt usa crypto/rand y no math/rand: el salt es parte del esquema de // firma, así que tiene que ser impredecible, no solo variado. func generarSalt() (string, error) { b := make([]byte, 12) if _, err := rand.Read(b); err != nil { return "", err } const alfabeto = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" for i := range b { b[i] = alfabeto[int(b[i])%len(alfabeto)] } return string(b), nil } // firmaRapyd arma la firma en el orden exacto que espera Rapyd. El resultado // va en base64 URL-safe sin padding. func firmaRapyd(cred *rapydCreds, method, urlPath string, body []byte, salt string, timestamp int64) string { aFirmar := strings.ToLower(method) + urlPath + salt + strconv.FormatInt(timestamp, 10) + cred.accessKey + string(body) h := hmac.New(sha256.New, []byte(cred.secretKey)) h.Write([]byte(aFirmar)) return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil)) } // MakeRequest llama a la API de Rapyd. path es la ruta (ej. "/v1/ewallets"). func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) { cred, err := rapydConfig() if err != nil { return nil, err } var bodyBytes []byte if body != nil { bodyBytes, err = json.Marshal(body) if err != nil { return nil, err } } // La firma se calcula sobre la ruta, no sobre la URL completa. u, err := url.Parse(cred.baseURL + path) if err != nil { return nil, err } urlPath := u.Path salt, err := generarSalt() if err != nil { return nil, err } timestamp := time.Now().Unix() req, err := http.NewRequest(strings.ToUpper(method), u.String(), bytes.NewReader(bodyBytes)) if err != nil { return nil, err } req.Header.Set("access_key", cred.accessKey) req.Header.Set("salt", salt) req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10)) req.Header.Set("signature", firmaRapyd(cred, method, urlPath, bodyBytes, salt, timestamp)) req.Header.Set("idempotency", strconv.FormatInt(time.Now().UnixNano(), 10)) req.Header.Set("Content-Type", "application/json") // Sin logging de la petición: el cuerpo de un wallet lleva nombre, correo, // teléfono, documento y fecha de nacimiento, y las cabeceras llevan la // access_key y la firma. client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("no se pudo contactar Rapyd: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("Rapyd respondió %d: %s", resp.StatusCode, string(respBody)) } var result map[string]interface{} if err := json.Unmarshal(respBody, &result); err != nil { return nil, fmt.Errorf("respuesta inesperada de Rapyd: %s", string(respBody)) } return result, nil }