Module: Jamm::Webhook
- Defined in:
- lib/jamm/webhook.rb
Class Method Summary collapse
-
.extract_raw_content(raw_body) ⇒ Object
Extracts the top-level
contentvalue substring from a raw webhook body verbatim, without decoding or re-serializing it, so the exact signed bytes are recovered. -
.parse(json) ⇒ Object
Parse command is for parsing the received webhook message.
-
.secure_compare(a, b) ⇒ Object
Securely compare two strings of equal length.
-
.skip_string(str, i) ⇒ Object
ipoints at an opening '"'. -
.skip_value(str, i) ⇒ Object
ipoints at the first char of a JSON value. -
.verify(data:, signature:) ⇒ Object
Verify message.
-
.verify_and_parse(raw_body) ⇒ Object
Verify the HMAC signature over the exact received bytes and parse, in one step.
Class Method Details
.extract_raw_content(raw_body) ⇒ Object
Extracts the top-level content value substring from a raw webhook body verbatim,
without decoding or re-serializing it, so the exact signed bytes are recovered.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/jamm/webhook.rb', line 90 def self.extract_raw_content(raw_body) i = 0 n = raw_body.length i += 1 while i < n && raw_body[i].match?(/\s/) raise ArgumentError, 'Webhook body must be a JSON object' unless raw_body[i] == '{' i += 1 # Scan every top-level key. Duplicate keys are rejected: JSON.parse keeps the LAST # occurrence while this returns the FIRST, so a duplicate `content` could otherwise # verify one payload and parse another (signature bypass). seen = {} content = nil loop do i += 1 while i < n && (raw_body[i].match?(/\s/) || raw_body[i] == ',') break if i >= n || raw_body[i] == '}' raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == '"' key_start = i i = skip_string(raw_body, i) # Decode the key (not a raw slice): otherwise an escaped duplicate such as # "content" would evade both the duplicate check and the 'content' match below, # while JSON.parse collapses it to `content` and keeps the last value. Normalize a # malformed key to ArgumentError to match the rest of this method. key = begin JSON.parse(raw_body[key_start...i]) rescue JSON::ParserError raise ArgumentError, 'Malformed webhook JSON' end raise ArgumentError, "Duplicate top-level key in webhook body: #{key}" if seen.key?(key) seen[key] = true i += 1 while i < n && raw_body[i].match?(/\s/) raise ArgumentError, 'Malformed webhook JSON' unless raw_body[i] == ':' i += 1 i += 1 while i < n && raw_body[i].match?(/\s/) value_start = i i = skip_value(raw_body, i) content = raw_body[value_start...i] if key == 'content' end raise ArgumentError, "Webhook body does not contain 'content' field" if content.nil? content end |
.parse(json) ⇒ Object
Parse command is for parsing the received webhook message. It does not call anything remotely, instead returns the suitable object.
13 14 15 16 17 18 19 20 21 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 |
# File 'lib/jamm/webhook.rb', line 13 def self.parse(json) out = Jamm::OpenAPI::MerchantWebhookMessage.new(json) case json[:event_type] when Jamm::OpenAPI::EventType::CHARGE_CREATED out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::CHARGE_UPDATED out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::REFUND_SUCCEEDED out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::REFUND_FAILED out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::CHARGE_SUCCESS out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::CHARGE_FAIL out.content = Jamm::OpenAPI::ChargeMessage.new(json[:content]) return out when Jamm::OpenAPI::EventType::CONTRACT_ACTIVATED out.content = Jamm::OpenAPI::ContractMessage.new(json[:content]) return out end raise 'Unknown event type' end |
.secure_compare(a, b) ⇒ Object
Securely compare two strings of equal length. This method is a port of ActiveSupport::SecurityUtils.secure_compare which works on non-Rails platforms.
181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/jamm/webhook.rb', line 181 def self.secure_compare(a, b) return false unless a.bytesize == b.bytesize # Unpack strings into arrays of bytes a_bytes = a.unpack('C*') b_bytes = b.unpack('C*') result = 0 # XOR each byte and accumulate the result a_bytes.zip(b_bytes) { |x, y| result |= x ^ y } result.zero? end |
.skip_string(str, i) ⇒ Object
i points at an opening '"'. Returns the index just past the closing '"'.
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/jamm/webhook.rb', line 136 def self.skip_string(str, i) i += 1 n = str.length while i < n c = str[i] if c == '\\' i += 2 next end return i + 1 if c == '"' i += 1 end raise ArgumentError, 'Unterminated string in webhook JSON' end |
.skip_value(str, i) ⇒ Object
i points at the first char of a JSON value. Returns the index just past it.
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/jamm/webhook.rb', line 153 def self.skip_value(str, i) return skip_string(str, i) if str[i] == '"' if str[i] == '{' || str[i] == '[' depth = 0 n = str.length while i < n ch = str[i] if ch == '"' i = skip_string(str, i) next elsif ch == '{' || ch == '[' depth += 1 elsif ch == '}' || ch == ']' depth -= 1 return i + 1 if depth.zero? end i += 1 end raise ArgumentError, 'Unterminated object/array in webhook JSON' end i += 1 while i < str.length && !",}] \t\n\r".include?(str[i]) i end |
.verify(data:, signature:) ⇒ Object
Verify message. This method will use client secret to verify the message.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/jamm/webhook.rb', line 51 def self.verify(data:, signature:) raise ArgumentError, 'data cannot be nil' if data.nil? raise ArgumentError, 'signature cannot be nil' if signature.nil? # Convert the JSON to a string json = JSON.dump(data) digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), Jamm.client_secret, json) given = "sha256=#{digest}" return if secure_compare(given, signature) raise Jamm::InvalidSignatureError, 'Digests do not match' end |
.verify_and_parse(raw_body) ⇒ Object
Verify the HMAC signature over the exact received bytes and parse, in one step.
This is the recommended entry point. The backend signs the raw content bytes it
transmits, produced by Go's JSON encoder which HTML-escapes & < > as & <
. Re-serializing the parsed content (as
verifydoes via JSON.dump) un-escapes those characters, so its digest no longer matches. This slices the rawcontentsubstring out ofraw_bodyverbatim and HMACs that.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/jamm/webhook.rb', line 73 def self.verify_and_parse(raw_body) raise ArgumentError, 'raw_body cannot be nil or empty' if raw_body.nil? || raw_body.empty? parsed = JSON.parse(raw_body, symbolize_names: true) signature = parsed[:signature] raise ArgumentError, "Webhook body is missing the 'signature' field" if signature.nil? || signature.empty? raw_content = extract_raw_content(raw_body) digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), Jamm.client_secret, raw_content) given = "sha256=#{digest}" raise Jamm::InvalidSignatureError, 'Digests do not match' unless secure_compare(given, signature) parse(parsed) end |