Module: FluvPay::Webhooks

Defined in:
lib/fluvpay/webhooks.rb

Overview

Verificação de assinatura de webhooks da FluvPay.

A FluvPay assina cada entrega com HMAC-SHA256 sobre “{timestamp}.” corpo_cru+, usando o segredo whsec_... do webhook. O header X-FluvPay-Signature traz v1=<hex>. A verificação usa comparação em tempo constante e exige o corpo CRU (a string exatamente como recebida), nunca reserializado.

Defined Under Namespace

Classes: Event

Constant Summary collapse

EVENT_HEADER =
"X-FluvPay-Event"
TIMESTAMP_HEADER =
"X-FluvPay-Timestamp"
DELIVERY_ID_HEADER =
"X-FluvPay-Delivery-Id"
SIGNATURE_HEADER =
"X-FluvPay-Signature"
EVENT_TYPES =

Eventos disponíveis (8). Espelha o catálogo do contrato OpenAPI.

%w[
  charge.created
  charge.paid
  charge.expired
  charge.cancelled
  charge.refunded
  payout.created
  payout.completed
  payout.failed
].freeze

Class Method Summary collapse

Class Method Details

.compute_signature(secret, timestamp, raw_body) ⇒ String

Recalcula o hex da assinatura: HMAC_SHA256(secret, timestamp + “.” + corpo_cru).

Parameters:

  • secret (String)

    segredo do webhook (whsec_...).

  • timestamp (String)

    valor de X-FluvPay-Timestamp.

  • raw_body (String)

    corpo CRU da requisição.

Returns:

  • (String)

    assinatura em hexadecimal.



62
63
64
65
# File 'lib/fluvpay/webhooks.rb', line 62

def compute_signature(secret, timestamp, raw_body)
  signed_payload = "#{timestamp}.#{raw_body}"
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA256"), secret.to_s, signed_payload)
end

.extract_v1(signature_header) ⇒ Object

Extrai o hex após v1= (aceita vários esquemas separados por vírgula).



117
118
119
120
121
122
123
124
125
# File 'lib/fluvpay/webhooks.rb', line 117

def extract_v1(signature_header)
  return nil if signature_header.nil? || signature_header.empty?

  signature_header.split(",").each do |part|
    item = part.strip
    return item[3..].to_s.strip if item.start_with?("v1=")
  end
  nil
end

.secure_compare(expected, provided) ⇒ Object

Comparação de strings em tempo constante, resistente a timing attacks.



147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/fluvpay/webhooks.rb', line 147

def secure_compare(expected, provided)
  a = expected.to_s.b
  b = provided.to_s.b
  return false unless a.bytesize == b.bytesize

  OpenSSL.fixed_length_secure_compare(a, b)
rescue StandardError
  # Fallback puro Ruby caso fixed_length_secure_compare não esteja disponível.
  bytes = a.unpack("C*")
  res = 0
  b.unpack("C*").each_with_index { |byte, i| res |= byte ^ bytes[i] }
  res.zero?
end

.verify_signature(payload, signature_header, timestamp, secret, tolerance_seconds: nil, event_type: nil, delivery_id: nil) ⇒ FluvPay::Webhooks::Event

Verifica a assinatura de um webhook e devolve o evento parseado.

Parameters:

  • payload (String)

    corpo CRU da requisição, exatamente como recebido.

  • signature_header (String)

    valor de X-FluvPay-Signature (formato v1=<hex>).

  • timestamp (String)

    valor de X-FluvPay-Timestamp.

  • secret (String)

    segredo do webhook (whsec_...).

  • tolerance_seconds (Integer, nil) (defaults to: nil)

    se informado e o timestamp for numérico, rejeita entregas mais antigas que esse limite (proteção contra replay).

  • event_type (String, nil) (defaults to: nil)

    valor de X-FluvPay-Event (preenche Event#type).

  • delivery_id (String, nil) (defaults to: nil)

    valor de X-FluvPay-Delivery-Id.

Returns:

Raises:



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/fluvpay/webhooks.rb', line 80

def verify_signature(payload, signature_header, timestamp, secret,
                     tolerance_seconds: nil, event_type: nil, delivery_id: nil)
  provided = extract_v1(signature_header.to_s)
  if provided.nil? || provided.empty?
    raise SignatureVerificationError.new(
      "Assinatura ausente ou em formato inválido (esperado 'v1=<hex>')."
    )
  end

  check_tolerance!(timestamp, tolerance_seconds) unless tolerance_seconds.nil?

  expected = compute_signature(secret, timestamp, payload)
  unless secure_compare(expected, provided)
    raise SignatureVerificationError.new("Assinatura do webhook não confere.")
  end

  parsed = parse_json(payload)
  resolved_type = event_type || (parsed.is_a?(Hash) ? parsed["event"] : nil)
  data =
    if parsed.is_a?(Hash) && parsed["data"].is_a?(Hash)
      parsed["data"]
    elsif parsed.is_a?(Hash)
      parsed
    else
      {}
    end

  Event.new(
    type: resolved_type,
    delivery_id: delivery_id,
    timestamp: timestamp,
    data: data,
    raw: parsed.is_a?(Hash) ? parsed : {}
  )
end