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