hook0-client 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +235 -0
- data/assets/ruby-flow.svg +153 -0
- data/lib/hook0/client.rb +691 -0
- data/lib/hook0/errors.rb +78 -0
- data/lib/hook0/generated/all.rb +7 -0
- data/lib/hook0/generated/api.rb +1097 -0
- data/lib/hook0/generated/errors.rb +235 -0
- data/lib/hook0/generated/models.rb +3093 -0
- data/lib/hook0/runtime.rb +299 -0
- data/lib/hook0/signature.rb +294 -0
- data/lib/hook0/transport.rb +359 -0
- data/lib/hook0/version.rb +6 -0
- data/lib/hook0.rb +23 -0
- metadata +90 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Hook0
|
|
8
|
+
# What the generated half of this gem reads and writes values through.
|
|
9
|
+
#
|
|
10
|
+
# Everything here is hand-written and never regenerated. It is the one seam between what the API
|
|
11
|
+
# declares — the classes, the problems and the methods the generator writes under `generated/` —
|
|
12
|
+
# and what it does not: how a JSON document is turned into a value, and what happens to a document
|
|
13
|
+
# that does not say what it was declared to say.
|
|
14
|
+
#
|
|
15
|
+
# Reading is deliberately strict. A member the document declares as a string and the API answered
|
|
16
|
+
# as a number stops the read with the name of that member, rather than yielding an object whose
|
|
17
|
+
# documentation lies about what it holds. Every failure of that kind is a {DecodeError}, so a
|
|
18
|
+
# caller has one thing to rescue whatever the shape of the answer was.
|
|
19
|
+
#
|
|
20
|
+
# A reader is anything answering to `call`. The scalar ones are constants, since there is exactly
|
|
21
|
+
# one of each; the ones built around another reader are methods, since there is one per shape.
|
|
22
|
+
module Runtime
|
|
23
|
+
# What the API answered is not what it declares it answers.
|
|
24
|
+
class DecodeError < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Longest fragment of a response body an error message carries. Bodies are answered by a server
|
|
27
|
+
# this gem does not control, so they are cut at a fixed budget rather than echoed whole into
|
|
28
|
+
# whatever the caller logs.
|
|
29
|
+
MAX_PREVIEW_BYTES = 256
|
|
30
|
+
|
|
31
|
+
# Largest JSON document read out of a response body, in bytes. The transport caps what it reads
|
|
32
|
+
# off a socket; this caps what is handed to the parser whichever way the bytes arrived.
|
|
33
|
+
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# Deepest a JSON document may nest before the parser gives up, which is what keeps a document
|
|
36
|
+
# that is nothing but brackets from growing the stack.
|
|
37
|
+
MAX_PAYLOAD_NESTING = 64
|
|
38
|
+
|
|
39
|
+
# The characters a path segment carries as themselves; everything else travels percent-encoded.
|
|
40
|
+
UNRESERVED = /[^A-Za-z0-9\-._~]/
|
|
41
|
+
|
|
42
|
+
# The shape a UUID is written in, whichever version it carries.
|
|
43
|
+
UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
44
|
+
|
|
45
|
+
# A string, refusing what merely spells like one.
|
|
46
|
+
TEXT = lambda { |value|
|
|
47
|
+
raise DecodeError, "expected a string, got #{value.class}" unless value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# A UUID, as the document spells one. It travels as the text the API answered, since that text
|
|
53
|
+
# is what has to go back out unchanged.
|
|
54
|
+
UUID = lambda { |value|
|
|
55
|
+
text = TEXT.call(value)
|
|
56
|
+
raise DecodeError, "expected a UUID, got `#{text}`" unless UUID_PATTERN.match?(text)
|
|
57
|
+
|
|
58
|
+
text
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# A whole number. `true` is not one, here or on the wire.
|
|
62
|
+
INTEGER = lambda { |value|
|
|
63
|
+
raise DecodeError, "expected a whole number, got #{value.class}" unless value.is_a?(Integer)
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# A number, whether the document wrote it with a fractional part or not.
|
|
69
|
+
FLOAT = lambda { |value|
|
|
70
|
+
raise DecodeError, "expected a number, got #{value.class}" unless value.is_a?(Integer) || value.is_a?(Float)
|
|
71
|
+
|
|
72
|
+
value.to_f
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# A boolean, refusing the numbers that stand in for one elsewhere.
|
|
76
|
+
BOOLEAN = lambda { |value|
|
|
77
|
+
raise DecodeError, "expected a boolean, got #{value.class}" unless [true, false].include?(value)
|
|
78
|
+
|
|
79
|
+
value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# A moment, as RFC 3339 spells one.
|
|
83
|
+
DATE_TIME = lambda { |value|
|
|
84
|
+
text = TEXT.call(value)
|
|
85
|
+
begin
|
|
86
|
+
Time.iso8601(text)
|
|
87
|
+
rescue ArgumentError => e
|
|
88
|
+
raise DecodeError, "expected a date and a time, got `#{text}`: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# A day, as ISO 8601 spells one.
|
|
93
|
+
DATE = lambda { |value|
|
|
94
|
+
text = TEXT.call(value)
|
|
95
|
+
begin
|
|
96
|
+
Date.iso8601(text)
|
|
97
|
+
rescue ArgumentError => e
|
|
98
|
+
# `Date::Error` is an `ArgumentError`, so rescuing the one covers the other.
|
|
99
|
+
raise DecodeError, "expected a date, got `#{text}`: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# A value the document does not describe, which is therefore kept as it arrived.
|
|
104
|
+
JSON_VALUE = ->(value) { value }
|
|
105
|
+
|
|
106
|
+
# As much of a response body as a message may carry.
|
|
107
|
+
#
|
|
108
|
+
# @param payload [String]
|
|
109
|
+
# @return [String]
|
|
110
|
+
def self.preview(payload)
|
|
111
|
+
bytes = payload.to_s.b
|
|
112
|
+
kept = bytes.byteslice(0, MAX_PREVIEW_BYTES).force_encoding(Encoding::UTF_8)
|
|
113
|
+
rendered = kept.scrub("�")
|
|
114
|
+
bytes.bytesize > MAX_PREVIEW_BYTES ? "#{rendered}…" : rendered
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# What to say about an answer the API document does not describe.
|
|
118
|
+
#
|
|
119
|
+
# @param status [Integer]
|
|
120
|
+
# @param payload [String]
|
|
121
|
+
# @return [String]
|
|
122
|
+
def self.unreadable(status, payload)
|
|
123
|
+
"the API answered #{status} with a body this client cannot read: #{preview(payload)}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# What to say about a problem the API reported.
|
|
127
|
+
#
|
|
128
|
+
# @param status [Integer]
|
|
129
|
+
# @param problem [#to_h] the problem document the API answered
|
|
130
|
+
# @return [String]
|
|
131
|
+
def self.reported(status, problem)
|
|
132
|
+
"the API answered #{status}: #{problem.to_h}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The JSON document a response body carries.
|
|
136
|
+
#
|
|
137
|
+
# @param payload [String]
|
|
138
|
+
# @return [Object]
|
|
139
|
+
# @raise [DecodeError] when the body is larger than this gem reads, or is not JSON
|
|
140
|
+
def self.decode_payload(payload)
|
|
141
|
+
bytes = payload.to_s
|
|
142
|
+
if bytes.bytesize > MAX_PAYLOAD_BYTES
|
|
143
|
+
raise DecodeError, "the response is #{bytes.bytesize} bytes, above the #{MAX_PAYLOAD_BYTES} accepted"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
JSON.parse(bytes, max_nesting: MAX_PAYLOAD_NESTING)
|
|
148
|
+
rescue JSON::ParserError, EncodingError => e
|
|
149
|
+
raise DecodeError, "the response is not JSON: #{preview(bytes)} (#{e.message})"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The members of an object the document declares, under the name it declares it with.
|
|
154
|
+
#
|
|
155
|
+
# @param value [Object]
|
|
156
|
+
# @param owner [String] what the document calls the object being read
|
|
157
|
+
# @return [Hash]
|
|
158
|
+
# @raise [DecodeError] when the API answered something that is not an object
|
|
159
|
+
def self.as_fields(value, owner)
|
|
160
|
+
raise DecodeError, "#{owner} is not a JSON object" unless value.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A member the document requires, which is therefore missing when it is absent.
|
|
166
|
+
#
|
|
167
|
+
# @param fields [Hash]
|
|
168
|
+
# @param key [String] the name the member travels under
|
|
169
|
+
# @param reader [#call]
|
|
170
|
+
# @return [Object]
|
|
171
|
+
# @raise [DecodeError]
|
|
172
|
+
def self.read(fields, key, reader)
|
|
173
|
+
raise DecodeError, "`#{key}` is required and was not answered" unless fields.key?(key)
|
|
174
|
+
|
|
175
|
+
named(key) { reader.call(fields[key]) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A member the document does not require, absent as readily as answered as null.
|
|
179
|
+
#
|
|
180
|
+
# @param fields [Hash]
|
|
181
|
+
# @param key [String] the name the member travels under
|
|
182
|
+
# @param reader [#call]
|
|
183
|
+
# @return [Object, nil]
|
|
184
|
+
# @raise [DecodeError]
|
|
185
|
+
def self.maybe(fields, key, reader)
|
|
186
|
+
return nil if fields[key].nil?
|
|
187
|
+
|
|
188
|
+
named(key) { reader.call(fields[key]) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Every item of an array, each one read the same way.
|
|
192
|
+
#
|
|
193
|
+
# @param reader [#call]
|
|
194
|
+
# @return [Proc]
|
|
195
|
+
def self.list(reader)
|
|
196
|
+
lambda { |value|
|
|
197
|
+
raise DecodeError, "expected an array, got #{value.class}" unless value.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
value.map { |item| reader.call(item) }
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Every value of an object whose keys the document leaves open.
|
|
204
|
+
#
|
|
205
|
+
# @param reader [#call]
|
|
206
|
+
# @return [Proc]
|
|
207
|
+
def self.map(reader)
|
|
208
|
+
lambda { |value|
|
|
209
|
+
raise DecodeError, "expected an object, got #{value.class}" unless value.is_a?(Hash)
|
|
210
|
+
|
|
211
|
+
value.to_h { |key, item| [TEXT.call(key), reader.call(item)] }
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# One of the values a closed list declares, refusing anything the list does not carry.
|
|
216
|
+
#
|
|
217
|
+
# @param declared [#member?] the module the generator wrote for that list
|
|
218
|
+
# @return [Proc]
|
|
219
|
+
def self.member_of(declared)
|
|
220
|
+
lambda { |value|
|
|
221
|
+
text = TEXT.call(value)
|
|
222
|
+
raise DecodeError, "`#{text}` is not one of the values #{declared} declares" unless declared.member?(text)
|
|
223
|
+
|
|
224
|
+
text
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A moment, written the way the API reads one.
|
|
229
|
+
#
|
|
230
|
+
# A moment carrying no fraction of a second is written without one, and one that does keeps
|
|
231
|
+
# every digit it has, so that what was read comes back out unchanged either way.
|
|
232
|
+
#
|
|
233
|
+
# @param moment [Time]
|
|
234
|
+
# @return [String]
|
|
235
|
+
def self.moment(moment)
|
|
236
|
+
moment.nsec.zero? ? moment.iso8601 : moment.iso8601(9)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A day, written the way the API reads one.
|
|
240
|
+
#
|
|
241
|
+
# @param day [Date]
|
|
242
|
+
# @return [String]
|
|
243
|
+
def self.day(day)
|
|
244
|
+
day.iso8601
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Where a request lands, with each placeholder of the template filled in.
|
|
248
|
+
#
|
|
249
|
+
# @param template [String] the path as the document writes it, placeholders included
|
|
250
|
+
# @param filled [Hash{String => Object}] the value each placeholder carries
|
|
251
|
+
# @return [String]
|
|
252
|
+
def self.path(template, filled = {})
|
|
253
|
+
filled.reduce(template) do |written, (name, value)|
|
|
254
|
+
written.gsub("{#{name}}", path_segment(value))
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A value as one segment of a path, with nothing left in it that could name another one.
|
|
259
|
+
#
|
|
260
|
+
# @param value [Object]
|
|
261
|
+
# @return [String]
|
|
262
|
+
def self.path_segment(value)
|
|
263
|
+
written(value).b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# What travels in the query string: everything the document requires, and everything it does not
|
|
267
|
+
# that the caller actually passed.
|
|
268
|
+
#
|
|
269
|
+
# @param required [Array<Array>] name and value pairs the operation always sends
|
|
270
|
+
# @param optional [Array<Array>] name and value pairs it sends only when they carry something
|
|
271
|
+
# @return [Array<Array<String>>]
|
|
272
|
+
def self.query(required, optional = [])
|
|
273
|
+
asked = required.map { |name, value| [name, written(value)] }
|
|
274
|
+
asked + optional.filter_map { |name, value| [name, written(value)] unless value.nil? }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# How a value travels in a request line, which is not always how Ruby prints it.
|
|
278
|
+
#
|
|
279
|
+
# @param value [Object]
|
|
280
|
+
# @return [String]
|
|
281
|
+
def self.written(value)
|
|
282
|
+
case value
|
|
283
|
+
when true then "true"
|
|
284
|
+
when false then "false"
|
|
285
|
+
when Time then moment(value)
|
|
286
|
+
when Date then day(value)
|
|
287
|
+
else value.to_s
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Reads a member, saying which member it was that could not be read.
|
|
292
|
+
def self.named(key)
|
|
293
|
+
yield
|
|
294
|
+
rescue DecodeError => e
|
|
295
|
+
raise DecodeError, "`#{key}`: #{e.message}"
|
|
296
|
+
end
|
|
297
|
+
private_class_method :named
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
# Verifying that a webhook came from Hook0, and that nothing in it changed on the way.
|
|
8
|
+
module Hook0
|
|
9
|
+
# A signature header, read into the pieces a verification needs.
|
|
10
|
+
#
|
|
11
|
+
# A signature names the moment it was signed and one or two message authentication codes over the
|
|
12
|
+
# body. The `v1` scheme also covers a list of request headers, so a receiver can tell apart two
|
|
13
|
+
# deliveries that carry the same body but not the same context; `v0` covers the body alone and is
|
|
14
|
+
# what an older sender still produces. When both are offered, `v1` is the one verified: accepting
|
|
15
|
+
# the weaker of two schemes on the strength of the sender offering it is how a downgrade works.
|
|
16
|
+
#
|
|
17
|
+
# Two things are refused before any code is computed. A header the signature says it covers but
|
|
18
|
+
# the request did not carry is refused outright, because signing over an absent value would let a
|
|
19
|
+
# sender drop a header and keep the signature valid. And a signature whose codes are not whole
|
|
20
|
+
# hexadecimal is refused rather than decoded as far as it goes: a decoder that stops at the first
|
|
21
|
+
# bad character compares a prefix, and a prefix of the right code is not the right code.
|
|
22
|
+
class Signature
|
|
23
|
+
# Longest signature header read. The header is written by whoever reached the endpoint, so its
|
|
24
|
+
# size is bounded before any of it is split, decoded or compared.
|
|
25
|
+
MAX_SIGNATURE_BYTES = 8 * 1024
|
|
26
|
+
|
|
27
|
+
# Most `key=value` parts one signature header is split into.
|
|
28
|
+
MAX_SIGNATURE_PARTS = 32
|
|
29
|
+
|
|
30
|
+
# Most header names one signature covers.
|
|
31
|
+
MAX_COVERED_HEADERS = 64
|
|
32
|
+
|
|
33
|
+
# Furthest from the epoch, in either direction, a signature's moment may sit. Ruby's integers
|
|
34
|
+
# grow without bound, so a header carrying thousands of digits would otherwise reach the
|
|
35
|
+
# arithmetic that holds it against the current time and cost more than reading it did.
|
|
36
|
+
MAX_TIMESTAMP = 10**12
|
|
37
|
+
|
|
38
|
+
# What separates one part of the signature header from the next.
|
|
39
|
+
PART_SEPARATOR = ","
|
|
40
|
+
|
|
41
|
+
# What separates the name of a part from its value. Only the first one counts: a value may hold
|
|
42
|
+
# further ones, and splitting on all of them would silently drop everything past the second.
|
|
43
|
+
PART_ASSIGNATOR = "="
|
|
44
|
+
|
|
45
|
+
# What separates two header names inside the `h` part, and what they are joined back with.
|
|
46
|
+
HEADER_NAME_SEPARATOR = " "
|
|
47
|
+
|
|
48
|
+
# What separates the pieces of the message a code is computed over.
|
|
49
|
+
MESSAGE_SEPARATOR = "."
|
|
50
|
+
|
|
51
|
+
# Part naming the moment the delivery was signed, in whole seconds since the Unix epoch.
|
|
52
|
+
TIMESTAMP_PART = "t"
|
|
53
|
+
|
|
54
|
+
# Part carrying the code covering the body alone.
|
|
55
|
+
BODY_SCHEME_PART = "v0"
|
|
56
|
+
|
|
57
|
+
# Part carrying the code covering the covered headers and the body.
|
|
58
|
+
HEADERS_SCHEME_PART = "v1"
|
|
59
|
+
|
|
60
|
+
# Part listing the headers the `v1` code covers, in the order it covers them.
|
|
61
|
+
COVERED_HEADERS_PART = "h"
|
|
62
|
+
|
|
63
|
+
# What a whole number of seconds reads as. `Integer()` would accept `1_0` as ten, which is a
|
|
64
|
+
# spelling no sender produces and no receiver should invent a meaning for.
|
|
65
|
+
WHOLE_SECONDS = /\A-?\d+\z/
|
|
66
|
+
|
|
67
|
+
# What a code reads as: whole pairs of hexadecimal digits, and nothing else.
|
|
68
|
+
WHOLE_HEXADECIMAL = /\A(?:\h\h)+\z/
|
|
69
|
+
|
|
70
|
+
# What a header name is written with, as RFC 9110 spells a token.
|
|
71
|
+
HEADER_NAME = /\A[A-Za-z0-9!\#$%&'*+\-.^_`|~]+\z/
|
|
72
|
+
|
|
73
|
+
# What the codes are computed with.
|
|
74
|
+
DIGEST = "SHA256"
|
|
75
|
+
|
|
76
|
+
# @return [Integer] the moment the delivery was signed, in whole seconds since the epoch
|
|
77
|
+
attr_reader :timestamp
|
|
78
|
+
|
|
79
|
+
# @return [Array<String>] the headers the stronger scheme covers, lowercased and in order
|
|
80
|
+
attr_reader :covered_headers
|
|
81
|
+
|
|
82
|
+
# @return [String, nil] the `v0` code, decoded
|
|
83
|
+
attr_reader :body_code
|
|
84
|
+
|
|
85
|
+
# @return [String, nil] the `v1` code, decoded
|
|
86
|
+
attr_reader :headers_code
|
|
87
|
+
|
|
88
|
+
# @param timestamp [Integer]
|
|
89
|
+
# @param covered_headers [Array<String>]
|
|
90
|
+
# @param body_code [String, nil]
|
|
91
|
+
# @param headers_code [String, nil]
|
|
92
|
+
def initialize(timestamp, covered_headers, body_code, headers_code)
|
|
93
|
+
@timestamp = timestamp
|
|
94
|
+
@covered_headers = covered_headers
|
|
95
|
+
@body_code = body_code
|
|
96
|
+
@headers_code = headers_code
|
|
97
|
+
freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reads a signature header, refusing anything it cannot read whole.
|
|
101
|
+
#
|
|
102
|
+
# @param signature [String] the value of the `X-Hook0-Signature` header
|
|
103
|
+
# @return [Signature]
|
|
104
|
+
# @raise [ClientError] for every way a header can fail to be one
|
|
105
|
+
def self.parse(signature)
|
|
106
|
+
raise ClientError, "the signature is #{signature.class}, not a header value" unless signature.is_a?(String)
|
|
107
|
+
|
|
108
|
+
if signature.length > MAX_SIGNATURE_BYTES
|
|
109
|
+
raise ClientError,
|
|
110
|
+
"the signature is #{signature.length} characters long, above the #{MAX_SIGNATURE_BYTES} accepted"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
read = parts_of(signature)
|
|
114
|
+
raise ClientError, "the signature carries neither a timestamp nor a code" if read.size < 2
|
|
115
|
+
|
|
116
|
+
body_code = code_of(read, BODY_SCHEME_PART)
|
|
117
|
+
headers_code = code_of(read, HEADERS_SCHEME_PART)
|
|
118
|
+
if body_code.nil? && headers_code.nil?
|
|
119
|
+
raise ClientError, "the signature carries neither a `#{BODY_SCHEME_PART}` nor a `#{HEADERS_SCHEME_PART}` code"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
new(timestamp_of(read), covered_headers_of(read), body_code, headers_code)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The `key=value` parts of a header, split on the first assignator of each and trimmed.
|
|
126
|
+
def self.parts_of(signature)
|
|
127
|
+
parts = signature.split(PART_SEPARATOR, -1)
|
|
128
|
+
if parts.size > MAX_SIGNATURE_PARTS
|
|
129
|
+
raise ClientError, "the signature carries more than the #{MAX_SIGNATURE_PARTS} parts accepted"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parts.each_with_object({}) do |part, read|
|
|
133
|
+
name, assigned, value = part.partition(PART_ASSIGNATOR)
|
|
134
|
+
read[name.strip] = value.strip unless assigned.empty?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
private_class_method :parts_of
|
|
138
|
+
|
|
139
|
+
# The moment the signature names, which it is not a signature without.
|
|
140
|
+
def self.timestamp_of(read)
|
|
141
|
+
written = read[TIMESTAMP_PART]
|
|
142
|
+
raise ClientError, "the signature carries no `#{TIMESTAMP_PART}` part" if written.nil?
|
|
143
|
+
raise ClientError, "`#{written}` is not a number of seconds" unless WHOLE_SECONDS.match?(written)
|
|
144
|
+
|
|
145
|
+
seconds = Integer(written, 10)
|
|
146
|
+
if seconds.abs > MAX_TIMESTAMP
|
|
147
|
+
raise ClientError, "the signature's moment is further than #{MAX_TIMESTAMP} seconds from the epoch"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
seconds
|
|
151
|
+
end
|
|
152
|
+
private_class_method :timestamp_of
|
|
153
|
+
|
|
154
|
+
# One of the codes a signature offers, decoded whole or not at all.
|
|
155
|
+
def self.code_of(read, part)
|
|
156
|
+
written = read[part]
|
|
157
|
+
return nil if written.nil?
|
|
158
|
+
raise ClientError, "the `#{part}` code is not hexadecimal" unless WHOLE_HEXADECIMAL.match?(written)
|
|
159
|
+
|
|
160
|
+
[written].pack("H*")
|
|
161
|
+
end
|
|
162
|
+
private_class_method :code_of
|
|
163
|
+
|
|
164
|
+
# The headers the stronger scheme covers, in the order it covers them.
|
|
165
|
+
def self.covered_headers_of(read)
|
|
166
|
+
written = read[COVERED_HEADERS_PART]
|
|
167
|
+
return [] if written.nil? || written.empty?
|
|
168
|
+
|
|
169
|
+
names = written.split(HEADER_NAME_SEPARATOR, -1)
|
|
170
|
+
if names.size > MAX_COVERED_HEADERS
|
|
171
|
+
raise ClientError, "the signature covers more than the #{MAX_COVERED_HEADERS} headers accepted"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
names.map do |name|
|
|
175
|
+
raise ClientError, "`#{name}` is not a header name" unless HEADER_NAME.match?(name)
|
|
176
|
+
|
|
177
|
+
name.downcase
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
private_class_method :covered_headers_of
|
|
181
|
+
|
|
182
|
+
# Whether the code this signature carries is the one the secret produces.
|
|
183
|
+
#
|
|
184
|
+
# The stronger scheme wins when both are offered, and the comparison is made in constant time:
|
|
185
|
+
# one that gave up at the first differing byte would say, by how long it took, how much of a
|
|
186
|
+
# guess was right.
|
|
187
|
+
#
|
|
188
|
+
# @param payload [String] the raw body of the webhook request
|
|
189
|
+
# @param covered_values [Array<String>] the values of the covered headers, in order
|
|
190
|
+
# @param subscription_secret [String]
|
|
191
|
+
# @return [Boolean]
|
|
192
|
+
def matches?(payload, covered_values, subscription_secret)
|
|
193
|
+
code = OpenSSL::HMAC.new(subscription_secret.to_s, DIGEST)
|
|
194
|
+
code << @timestamp.to_s
|
|
195
|
+
code << MESSAGE_SEPARATOR
|
|
196
|
+
|
|
197
|
+
unless @headers_code.nil?
|
|
198
|
+
code << @covered_headers.join(HEADER_NAME_SEPARATOR)
|
|
199
|
+
code << MESSAGE_SEPARATOR
|
|
200
|
+
code << covered_values.join(MESSAGE_SEPARATOR)
|
|
201
|
+
code << MESSAGE_SEPARATOR
|
|
202
|
+
code << payload
|
|
203
|
+
return Signature.same_code?(code.digest, @headers_code)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A signature carrying neither code is refused while it is being read, so what is left here
|
|
207
|
+
# is the body-only scheme.
|
|
208
|
+
code << payload
|
|
209
|
+
Signature.same_code?(code.digest, @body_code)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Whether two codes are the same, without saying by how long it took how much of one was right.
|
|
213
|
+
#
|
|
214
|
+
# @param left [String]
|
|
215
|
+
# @param right [String]
|
|
216
|
+
# @return [Boolean]
|
|
217
|
+
def self.same_code?(left, right)
|
|
218
|
+
left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Verifies a webhook against a moment the caller names.
|
|
223
|
+
#
|
|
224
|
+
# The clock window is bilateral. A moment too far in the future is refused exactly like one too
|
|
225
|
+
# far in the past, so the window a given delivery is accepted in stays the width the caller asked
|
|
226
|
+
# for, whichever way a clock drifted.
|
|
227
|
+
#
|
|
228
|
+
# @param signature [String] the value of the `X-Hook0-Signature` header
|
|
229
|
+
# @param payload [String] the raw body of the webhook request
|
|
230
|
+
# @param headers [Hash, Array<Array>] the headers of the webhook request
|
|
231
|
+
# @param subscription_secret [String] the signing secret of the subscription it was delivered for
|
|
232
|
+
# @param tolerance [Numeric] how far, in seconds and in either direction, the moment the signature
|
|
233
|
+
# names may sit from `current_time`. Five minutes is a reasonable trade-off between tolerating
|
|
234
|
+
# clock drift and bounding how long a captured delivery can be replayed.
|
|
235
|
+
# @param current_time [Time] what to hold the signature's moment against
|
|
236
|
+
# @return [void]
|
|
237
|
+
# @raise [ClientError] for every reason a webhook may be refused
|
|
238
|
+
def self.verify_webhook_signature_with_current_time(
|
|
239
|
+
signature, payload, headers, subscription_secret, tolerance, current_time
|
|
240
|
+
)
|
|
241
|
+
parsed = Signature.parse(signature)
|
|
242
|
+
|
|
243
|
+
delivered = delivered_headers(headers)
|
|
244
|
+
covered_values = parsed.covered_headers.map do |name|
|
|
245
|
+
raise ClientError, "the `#{name}` header the signature covers was not delivered" unless delivered.key?(name)
|
|
246
|
+
|
|
247
|
+
delivered[name]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
unless parsed.matches?(payload.to_s, covered_values, subscription_secret)
|
|
251
|
+
raise ClientError, "the signature does not match what the subscription secret produces"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
drift = current_time.to_f - parsed.timestamp
|
|
255
|
+
if drift.abs > tolerance
|
|
256
|
+
raise ClientError,
|
|
257
|
+
"the signature was made #{format("%.0f", drift)} seconds from now, outside the #{tolerance} accepted"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Verifies a webhook against the current moment.
|
|
264
|
+
#
|
|
265
|
+
# See {verify_webhook_signature_with_current_time} for what each argument is.
|
|
266
|
+
#
|
|
267
|
+
# @return [void]
|
|
268
|
+
# @raise [ClientError]
|
|
269
|
+
def self.verify_webhook_signature(signature, payload, headers, subscription_secret, tolerance)
|
|
270
|
+
verify_webhook_signature_with_current_time(
|
|
271
|
+
signature, payload, headers, subscription_secret, tolerance, Time.now
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The headers of the request, under the names a signature refers to them by.
|
|
276
|
+
#
|
|
277
|
+
# A later value wins over an earlier one under the same name, which is what a hash built by the
|
|
278
|
+
# caller would have done.
|
|
279
|
+
def self.delivered_headers(headers)
|
|
280
|
+
headers.to_a.each_with_object({}) do |(name, value), delivered|
|
|
281
|
+
delivered[header_text(name).downcase] = header_text(value)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
private_class_method :delivered_headers
|
|
285
|
+
|
|
286
|
+
# A header name or value as text, whichever way the caller holds it.
|
|
287
|
+
def self.header_text(value)
|
|
288
|
+
raise ClientError, "a header is #{value.class}, not a header value" unless value.is_a?(String)
|
|
289
|
+
raise ClientError, "a header is not UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
290
|
+
|
|
291
|
+
value
|
|
292
|
+
end
|
|
293
|
+
private_class_method :header_text
|
|
294
|
+
end
|