Module: Rerout::Webhooks

Defined in:
lib/rerout/webhooks.rb

Overview

Webhook signature verification.

Rerout signs every webhook delivery with an ‘X-Rerout-Signature` header shaped as `t=unix_seconds,v1=hex_hmac_sha256`. The HMAC is computed over `“timestamp.raw_body”` with the endpoint signing secret (`whsec_…`) as the key.

Examples:

Rack / Rails controller

raw = request.body.read
ok = Rerout::Webhooks.verify_signature(
  raw_body: raw,
  signature_header: request.headers['X-Rerout-Signature'],
  secret: ENV.fetch('REROUT_WEBHOOK_SECRET')
)
head(:unauthorized) and return unless ok

Constant Summary collapse

DEFAULT_TOLERANCE_SECONDS =

Default tolerance window in seconds between the ‘t=` timestamp and the current time. Five minutes — protects against captured-replay attacks.

300

Class Method Summary collapse

Class Method Details

.verify_signature(raw_body:, signature_header:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil) ⇒ Boolean

Verify a Rerout webhook signature.

Returns ‘true` only when the header parses cleanly, the timestamp is within `tolerance_seconds` of `now`, and the computed HMAC matches the supplied `v1` in constant time. Returns `false` for every failure mode —it never raises.

Parameters:

  • raw_body (String)

    the exact, unmodified request body bytes.

  • signature_header (String)

    value of the ‘X-Rerout-Signature` header.

  • secret (String)

    the endpoint signing secret (‘whsec_…`).

  • tolerance_seconds (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    staleness window. ‘0` disables the timestamp check entirely. Defaults to 300.

  • now (Proc, #call, nil) (defaults to: nil)

    injectable clock returning the current unix time in seconds. Defaults to ‘Time.now.to_i`. Useful for tests.

Returns:

  • (Boolean)


41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/rerout/webhooks.rb', line 41

def self.verify_signature(raw_body:, signature_header:, secret:,
                          tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
                          now: nil)
  return false if raw_body.nil?
  return false if signature_header.nil? || signature_header.to_s.empty?
  return false if secret.nil? || secret.to_s.empty?

  parsed = parse_header(signature_header.to_s)
  return false if parsed.nil?

  timestamp, v1 = parsed
  return false unless within_tolerance?(timestamp, tolerance_seconds, now)

  expected = OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, "#{timestamp}.#{raw_body}")
  secure_compare(expected, v1)
end