Module: Wire::Webhook

Defined in:
lib/wire/webhook.rb

Overview

Webhook verifies inbound webhook signatures.

Header format: “WirePayment-Signature: t=<unix>,v1=<hex>” where

hex = HMAC-SHA256(secret, "<t>.<rawBody>")

Verification runs on the RAW request body, before any JSON parsing.

Constant Summary collapse

SIGNATURE_HEADER =
"WirePayment-Signature"
DEFAULT_TOLERANCE_SECONDS =
300

Class Method Summary collapse

Class Method Details

.parse_header(header) ⇒ Object

parse_header extracts t (Integer) and v1 (hex String) from the header. Returns [nil, nil] when t is missing or not an integer.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/wire/webhook.rb', line 57

def parse_header(header)
  t = nil
  v1 = nil
  header.to_s.split(",").each do |part|
    k, v = part.strip.split("=", 2)
    case k
    when "t"
      parsed = Integer(v, exception: false)
      return [nil, nil] if parsed.nil?

      t = parsed
    when "v1"
      v1 = v
    end
  end
  [t, v1]
end

.secure_compare(a, b) ⇒ Object

Constant-time comparison. Prefers OpenSSL’s fixed-length compare, falling back to a Rack-style XOR comparison on older rubies.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/wire/webhook.rb', line 77

def secure_compare(a, b)
  a = a.to_s
  b = b.to_s
  if OpenSSL.respond_to?(:fixed_length_secure_compare)
    return false unless a.bytesize == b.bytesize

    OpenSSL.fixed_length_secure_compare(a, b)
  else
    return false unless a.bytesize == b.bytesize

    l = a.unpack("C*")
    res = 0
    b.each_byte { |byte| res |= byte ^ l.shift }
    res.zero?
  end
end

.verify(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS) ⇒ Object

Verify a webhook signature and return the parsed event (Hash).

Parameters:

  • payload (String)

    raw, unparsed request body.

  • header (String)

    value of the WirePayment-Signature header.

  • secret (String)

    endpoint signing secret (whsec_…).

  • tolerance (Integer) (defaults to: DEFAULT_TOLERANCE_SECONDS)

    max allowed clock skew in seconds.

Raises:



32
33
34
# File 'lib/wire/webhook.rb', line 32

def verify(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
  verify_at(payload, header, secret, tolerance: tolerance, now: Time.now.to_i)
end

.verify_at(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now:) ⇒ Object

verify_at is the testable core taking an explicit ‘now` (unix seconds).



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/wire/webhook.rb', line 37

def verify_at(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now:)
  t, v1 = parse_header(header)
  raise SignatureVerificationError, "malformed signature header" if t.nil? || v1.nil? || v1.empty?

  if (now - t).abs > tolerance
    raise SignatureVerificationError, "timestamp outside tolerance"
  end

  body = payload.to_s
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{body}")

  unless secure_compare(expected, v1)
    raise SignatureVerificationError, "signature mismatch"
  end

  JSON.parse(body)
end