Module: Jamm::Webhook

Defined in:
lib/jamm/webhook.rb

Class Method Summary collapse

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.

Raises:

  • (ArgumentError)


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 '"'.

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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 verify does via JSON.dump) un-escapes those characters, so its digest no longer matches. This slices the raw content substring out of raw_body verbatim and HMACs that.

Raises:

  • (ArgumentError)


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