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
- .blank?(value) ⇒ Boolean
- .check_tolerance(seconds) ⇒ Object
-
.decode_base64(value) ⇒ Object
Strict base64 decoding: padding is required and stray characters are refused.
-
.derive_key(secret) ⇒ String
Derive the HMAC key from a Standard Webhooks secret: strip the
whsec_prefix and base64 decode the rest. - .match?(candidate, expected) ⇒ Boolean
- .normalize_body(body) ⇒ Object
- .parse(payload) ⇒ Object
- .parse_timestamp(value) ⇒ Object
-
.secure_compare(left, right) ⇒ Object
Compare two strings without leaking their contents through timing.
-
.verify(body:, headers:, secret:) ⇒ Hash
Verify a raw webhook request and return its parsed payload.
- .verify_signature(body, headers, secret) ⇒ Object
Class Method Details
.blank?(value) ⇒ 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.
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
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
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 (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.
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"] = normalized["webhook-timestamp"] signature = normalized["webhook-signature"] raise WebhookVerificationError, "Missing required headers" if blank?(id) || blank?() || blank?(signature) check_tolerance(()) expected = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{}.#{body}".b) return if signature.to_s.split(" ").any? { |candidate| match?(candidate, expected) } raise WebhookVerificationError, "No matching signature found" end |