Module: Pago::WebhookVerifier

Defined in:
lib/pago/webhooks.rb

Overview

Signature verification for incoming webhooks.

Implements the Standard Webhooks specification: the webhook-id, webhook-timestamp and webhook-signature headers are required, the timestamp must sit within a five minute window, and the signature is an HMAC-SHA256 of id.timestamp.body keyed with the decoded secret — the base64 body of whsec_<base64>, never the secret string itself. The header may carry several space separated v1,<base64> signatures — any match accepts the payload, which is what makes secret rotation possible.

Constant Summary collapse

TOLERANCE_SECONDS =
5 * 60
SIGNATURE_VERSION =
"v1"
SECRET_PREFIX =
"whsec_"

Class Method Summary collapse

Class Method Details

.blank?(value) ⇒ Boolean

Returns:

  • (Boolean)


157
# File 'lib/pago/webhooks.rb', line 157

def blank?(value) = value.nil? || value.to_s.empty?

.check_tolerance(seconds) ⇒ Object



123
124
125
126
127
# File 'lib/pago/webhooks.rb', line 123

def check_tolerance(seconds)
  now = Time.now.to_i
  raise WebhookVerificationError, "Message timestamp too old" if seconds < now - TOLERANCE_SECONDS
  raise WebhookVerificationError, "Message timestamp too new" if seconds > now + TOLERANCE_SECONDS
end

.decode_base64(value) ⇒ Object

Strict base64 decoding: padding is required and stray characters are refused.



151
152
153
154
155
# File 'lib/pago/webhooks.rb', line 151

def decode_base64(value)
  value.to_s.unpack1("m0")
rescue ArgumentError
  nil
end

.derive_key(secret) ⇒ String

Derive the HMAC key from a Standard Webhooks secret: strip the whsec_ prefix and base64 decode the rest.

Parameters:

  • secret (String)

    the endpoint secret.

Returns:

  • (String)

    the raw signing key.

Raises:



103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/pago/webhooks.rb', line 103

def derive_key(secret)
  value = secret.to_s
  raise WebhookSecretError, "the secret is empty" if value.empty?
  raise WebhookSecretError, "missing the \"#{SECRET_PREFIX}\" prefix" unless value.start_with?(SECRET_PREFIX)

  encoded = value[SECRET_PREFIX.length..]
  raise WebhookSecretError, "nothing follows the prefix" if encoded.nil? || encoded.empty?

  key = decode_base64(encoded)
  raise WebhookSecretError, "the part after the prefix is not valid base64" if key.nil? || key.empty?

  key
end

.match?(candidate, expected) ⇒ Boolean

Returns:

  • (Boolean)


129
130
131
132
133
134
135
136
137
# File 'lib/pago/webhooks.rb', line 129

def match?(candidate, expected)
  version, separator, signature = candidate.partition(",")
  return false unless separator == "," && version == SIGNATURE_VERSION

  decoded = decode_base64(signature)
  return false if decoded.nil?

  secure_compare(expected, decoded)
end

.normalize_body(body) ⇒ Object

Raises:



65
66
67
68
69
70
# File 'lib/pago/webhooks.rb', line 65

def normalize_body(body)
  payload = body.to_s.dup.force_encoding(Encoding::UTF_8)
  raise WebhookError, "Failed to parse webhook payload" unless payload.valid_encoding?

  payload
end

.parse(payload) ⇒ Object



72
73
74
75
76
# File 'lib/pago/webhooks.rb', line 72

def parse(payload)
  JSON.parse(payload)
rescue JSON::ParserError
  raise WebhookError, "Failed to parse webhook payload"
end

.parse_timestamp(value) ⇒ Object



117
118
119
120
121
# File 'lib/pago/webhooks.rb', line 117

def parse_timestamp(value)
  Integer(value.to_s, 10)
rescue ArgumentError, TypeError
  raise WebhookVerificationError, "Invalid signature headers"
end

.secure_compare(left, right) ⇒ Object

Compare two strings without leaking their contents through timing.



140
141
142
143
144
145
146
147
148
# File 'lib/pago/webhooks.rb', line 140

def secure_compare(left, right)
  if OpenSSL.respond_to?(:secure_compare)
    OpenSSL.secure_compare(left, right)
  elsif defined?(Rack::Utils) && Rack::Utils.respond_to?(:secure_compare)
    Rack::Utils.secure_compare(left, right)
  else
    left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
  end
end

.verify(body:, headers:, secret:) ⇒ Hash

Verify a raw webhook request and return its parsed payload.

Parameters:

  • body (String)

    the raw request body, exactly as received.

  • headers (Hash{String => String})

    the request headers, case insensitive.

  • secret (String)

    the endpoint secret, in whsec_<base64> form.

Returns:

  • (Hash)

    the parsed payload.

Raises:



59
60
61
62
63
# File 'lib/pago/webhooks.rb', line 59

def verify(body:, headers:, secret:)
  payload = normalize_body(body)
  verify_signature(payload, headers, secret)
  parse(payload)
end

.verify_signature(body, headers, secret) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/pago/webhooks.rb', line 78

def verify_signature(body, headers, secret)
  key = derive_key(secret)

  normalized = (headers || {}).each_with_object({}) do |(name, value), result|
    result[name.to_s.downcase] = value
  end
  id = normalized["webhook-id"]
  timestamp = normalized["webhook-timestamp"]
  signature = normalized["webhook-signature"]
  raise WebhookVerificationError, "Missing required headers" if blank?(id) || blank?(timestamp) || blank?(signature)

  check_tolerance(parse_timestamp(timestamp))

  expected = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}".b)
  return if signature.to_s.split(" ").any? { |candidate| match?(candidate, expected) }

  raise WebhookVerificationError, "No matching signature found"
end