Module: Zeridion::Flare::Webhook

Defined in:
lib/zeridion_flare/webhook.rb

Overview

Verify outbound webhook signatures from Zeridion Flare.

Each delivery is signed with HMAC-SHA256 over <unix_timestamp>.<raw_body> using the subscription secret. The signature is sent in the X-Zeridion-Signature header in the form t=<unix_timestamp>,v1=<hex>.

Class Method Summary collapse

Class Method Details

.secure_compare(a, b) ⇒ Object

Constant-time string comparison (defends against timing-attack signature-guessing). OpenSSL.secure_compare is available in Ruby 3.0+; we fall back to a hand-rolled loop for older runtimes if it's missing.



59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/zeridion_flare/webhook.rb', line 59

def self.secure_compare(a, b)
  return false if a.bytesize != b.bytesize

  if OpenSSL.respond_to?(:secure_compare)
    OpenSSL.secure_compare(a, b)
  else
    l = a.unpack("C*")
    r = 0
    i = -1
    b.each_byte { |v| r |= v ^ l[i += 1] }
    r.zero?
  end
end

.verify(payload, signature_header, secret, tolerance_seconds: nil) ⇒ Boolean

Returns true iff the signature is valid (and within tolerance, if set).

Parameters:

  • payload (String)

    Raw request body. MUST be the exact bytes the server signed — do not re-encode parsed JSON.

  • signature_header (String)

    Full value of the X-Zeridion-Signature header.

  • secret (String)

    The subscription secret returned at creation.

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

    If set, reject signatures whose timestamp differs from the current time by more than this many seconds (replay protection; recommended: 300).

Returns:

  • (Boolean)

    true iff the signature is valid (and within tolerance, if set).



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/zeridion_flare/webhook.rb', line 22

def self.verify(payload, signature_header, secret, tolerance_seconds: nil)
  return false if signature_header.nil? || signature_header.empty?
  return false if secret.nil? || secret.empty?

  timestamp = nil
  signatures = []
  signature_header.split(",").each do |part|
    trimmed = part.strip
    eq = trimmed.index("=")
    next if eq.nil? || eq.zero?

    key = trimmed[0, eq]
    value = trimmed[(eq + 1)..]
    case key
    when "t"
      timestamp = Integer(value, 10) if value.match?(/\A\d+\z/)
    when "v1"
      signatures << value.downcase
    end
  end

  return false if timestamp.nil? || signatures.empty?

  if tolerance_seconds
    now = Time.now.to_i
    return false if (now - timestamp).abs > tolerance_seconds
  end

  signing_input = "#{timestamp}.#{payload}"
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signing_input)

  signatures.any? { |provided| secure_compare(expected, provided) }
end