fingerly 0.1.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.
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+ require "uri"
7
+ require "zlib"
8
+
9
+ module Fingerly
10
+ # One HTTP exchange, bounded end to end.
11
+ #
12
+ # Net::HTTP has +open_timeout+, +read_timeout+ and +write_timeout+, but each
13
+ # bounds a single operation: a server that answers and then dribbles a byte
14
+ # every second would satisfy all three forever. The socket's timeouts are
15
+ # shrunk to whatever is left of the deadline before each operation and each
16
+ # body chunk, and one Timeout around the whole exchange covers what those
17
+ # cannot reach: Net::HTTP reads the status line and every header line with
18
+ # the full read timeout each, so a server trickling header lines would
19
+ # otherwise hold the call for as long as it liked. Under a fiber scheduler
20
+ # Timeout hands the wait to the scheduler.
21
+ #
22
+ # Idle connections live in one pool per process, keyed by destination.
23
+ # Net::HTTP cannot be used by two threads or fibers at once, so each exchange
24
+ # checks a connection out and hands it back when it is done. A thread-local
25
+ # or fiber-local connection would be reused only by the same thread or
26
+ # fiber: under Falcon, which runs each client connection in its own fiber,
27
+ # that meant a new upstream socket per visitor, stranded until garbage
28
+ # collection. A pool shared by everything reuses a socket whoever asks next.
29
+ #
30
+ # Connections are recycled by age, because one held for the life of a
31
+ # process pins whatever DNS answered first, and dropped after sitting idle,
32
+ # because the other end will eventually close it without telling anybody.
33
+ # Keying by destination rather than by transport instance means an
34
+ # application that builds a client per request still reuses sockets.
35
+ #
36
+ # Nothing here needs an asynchronous twin. Net::HTTP has honoured the fiber
37
+ # scheduler since Ruby 3.0, so under Falcon or the async gem these same
38
+ # blocking reads yield to the scheduler instead of holding the thread.
39
+ #
40
+ # @api private
41
+ class Transport
42
+ include Redacted
43
+
44
+ # A response body is small; anything larger did not come from this API,
45
+ # and is refused rather than buffered.
46
+ MAX_BODY_BYTES = 4 * 1024 * 1024
47
+
48
+ # How long a pooled connection may live before it is reopened, so DNS is
49
+ # asked again at least this often.
50
+ RECYCLE_AFTER = 60
51
+
52
+ # How long a connection may sit idle in the pool and still be reused. Well
53
+ # under the idle timeouts servers and load balancers apply, so a reused
54
+ # socket is one the other end has not yet given up on.
55
+ IDLE_TIMEOUT = 15
56
+
57
+ # Idle connections kept per destination. Enough that a burst of a few
58
+ # hundred concurrent requests hands its sockets back rather than closing
59
+ # them; never more open at rest than this.
60
+ MAX_IDLE = 256
61
+
62
+ Result = Struct.new(:status, :headers, :body, keyword_init: true)
63
+
64
+ # @param origin [String] scheme and authority, without a trailing slash
65
+ # @param timeout [Numeric] the deadline for one exchange, in seconds
66
+ # @param configure_http [Proc, nil] receives the Net::HTTP before it starts
67
+ def initialize(origin:, timeout:, configure_http: nil)
68
+ @uri = URI.parse(origin)
69
+ @timeout = timeout
70
+ @configure_http = configure_http
71
+ # A caller-supplied configure_http may set anything at all, so two
72
+ # transports share a connection only when neither has one.
73
+ @pool_key = [@uri.scheme, @uri.host, @uri.port, configure_http&.object_id].freeze
74
+ end
75
+
76
+ # Performs one request and reads the whole response.
77
+ #
78
+ # @param method [Class] a Net::HTTPRequest subclass
79
+ # @param path [String] the path and query to request
80
+ # @param headers [Hash{String=>String}] headers to send
81
+ # @param body [String, nil] the request body
82
+ # @return [Result]
83
+ # @raise [TimeoutError] if the deadline passes at any point
84
+ # @raise [ConnectionError] if the API cannot be reached
85
+ def call(method, path, headers, body = nil)
86
+ request = method.new(path, headers)
87
+ request.body = body if body
88
+ # Net::HTTP's own retry would resend on a connection error; reuse is
89
+ # handled here instead, where an idempotent request can be told from one
90
+ # that must never be sent twice.
91
+ idempotent = method == Net::HTTP::Get
92
+
93
+ # One budget for the whole call, so reopening a closed connection cannot
94
+ # quietly buy a second timeout's worth of time.
95
+ Timeout.timeout(@timeout, DeadlineExceeded) do
96
+ perform(request, deadline: now + @timeout, idempotent: idempotent)
97
+ end
98
+ rescue DeadlineExceeded
99
+ # perform has already discarded the interrupted connection.
100
+ raise TimeoutError, "The Fingerly request timed out"
101
+ rescue Timeout::Error => e
102
+ # Net::OpenTimeout, ReadTimeout and WriteTimeout all derive from it.
103
+ raise TimeoutError, "The Fingerly request timed out (#{e.class})"
104
+ rescue SocketError, IOError, SystemCallError, OpenSSL::OpenSSLError, Zlib::Error,
105
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
106
+ # Every network failure arrives as the SDK's own error, never as a raw
107
+ # runtime one. The class names the cause; nothing from the body does.
108
+ raise ConnectionError, "Could not reach the Fingerly API (#{e.class})"
109
+ end
110
+
111
+ # Closes the idle connections kept for this destination.
112
+ def close
113
+ Transport.drain(@pool_key).each { |entry| discard(entry.http) }
114
+ end
115
+
116
+ # One entry per idle connection: the Net::HTTP, when it was opened, and
117
+ # when it was last handed back.
118
+ Idle = Struct.new(:http, :opened_at, :released_at)
119
+
120
+ @idle = {}
121
+ @lock = Mutex.new
122
+ @pid = Process.pid
123
+ @swept_at = nil
124
+
125
+ class << self
126
+ # @api private
127
+ # @return [Array(Idle, Array<Idle>)] the most recently used usable
128
+ # connection, or nil, and the stale ones the caller must close
129
+ def checkout(key, now)
130
+ @lock.synchronize do
131
+ list = idle_for(key)
132
+ stale = prune(list, now)
133
+ [list.pop, stale]
134
+ end
135
+ end
136
+
137
+ # @api private
138
+ # @return [Array(Boolean, Array<Idle>)] whether the pool kept it, and the
139
+ # stale ones the caller must close
140
+ def checkin(key, entry, now)
141
+ @lock.synchronize do
142
+ list = idle_for(key)
143
+ stale = prune(list, now)
144
+ kept = list.size < MAX_IDLE
145
+ list.push(entry) if kept
146
+ # After the push, so a sweep cannot drop the list just appended to.
147
+ [kept, stale + sweep(now)]
148
+ end
149
+ end
150
+
151
+ # @api private
152
+ def drain(key)
153
+ @lock.synchronize { idle_for(key).slice!(0..) }
154
+ end
155
+
156
+ # @api private
157
+ # @return [Integer] idle connections held for a destination
158
+ def idle_count(key)
159
+ @lock.synchronize { idle_for(key).size }
160
+ end
161
+
162
+ private
163
+
164
+ def idle_for(key)
165
+ # A forked child must never write to a socket its parent also holds.
166
+ # Dropping the references closes nothing the parent still uses.
167
+ unless @pid == Process.pid
168
+ @idle = {}
169
+ @pid = Process.pid
170
+ end
171
+ @idle[key] ||= []
172
+ end
173
+
174
+ # A destination nobody asks for again would otherwise keep its sockets
175
+ # for ever: each +configure_http+ proc is its own key, so a client built
176
+ # per request with an inline proc strands one socket per request.
177
+ #
178
+ # @return [Array<Idle>] the stale connections the caller must close
179
+ def sweep(now)
180
+ return [] if @swept_at && now - @swept_at < IDLE_TIMEOUT
181
+
182
+ @swept_at = now
183
+ stale = []
184
+ @idle.delete_if do |_key, list|
185
+ stale.concat(prune(list, now))
186
+ list.empty?
187
+ end
188
+ stale
189
+ end
190
+
191
+ # The oldest entries sit at the front; take them while they are stale.
192
+ def prune(list, now)
193
+ stale = []
194
+ stale << list.shift while list.first && stale?(list.first, now)
195
+ stale
196
+ end
197
+
198
+ def stale?(entry, now)
199
+ now - entry.released_at > IDLE_TIMEOUT || now - entry.opened_at > RECYCLE_AFTER
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+ def redacted_fields
206
+ [["origin", "#{@uri.scheme}://#{@uri.host}:#{@uri.port}"], ["timeout", @timeout]]
207
+ end
208
+
209
+ def perform(request, deadline:, idempotent:)
210
+ entry, reused = connection
211
+ begin
212
+ result = exchange(entry.http, request, deadline)
213
+ rescue IOError, SystemCallError, Net::HTTPBadResponse
214
+ discard(entry.http)
215
+ # A connection the other end closed while it was idle fails on its
216
+ # next use. Reopening and sending again is recovery, not a retry, so
217
+ # it happens only for a reused connection and only for a request that
218
+ # may safely arrive twice. A timeout is deliberately not in this list:
219
+ # a slow server is not a broken connection.
220
+ raise unless reused && idempotent
221
+
222
+ entry = fresh
223
+ begin
224
+ result = exchange(entry.http, request, deadline)
225
+ rescue Exception # rubocop:disable Lint/RescueException
226
+ discard(entry.http)
227
+ raise
228
+ end
229
+ rescue Exception # rubocop:disable Lint/RescueException
230
+ # Anything else, the overall deadline included, leaves the connection
231
+ # in an unknown state, so it is never handed to another caller.
232
+ discard(entry.http)
233
+ raise
234
+ end
235
+ release(entry)
236
+ result
237
+ end
238
+
239
+ def exchange(http, request, deadline)
240
+ apply_timeouts(http, deadline)
241
+ http.start unless http.started?
242
+ apply_timeouts(http, deadline)
243
+
244
+ # The result is captured rather than returned from the block. Net::HTTP
245
+ # decides whether the connection may be kept alive after the block ends,
246
+ # and a non-local exit would skip that: the next request on this thread
247
+ # would then be written to a socket the server had already said it was
248
+ # closing.
249
+ result = nil
250
+ http.request(request) do |response|
251
+ # Refused on the declared size before a byte is buffered, and on the
252
+ # real size while reading, whichever comes first.
253
+ too_large! if response.content_length.to_i > MAX_BODY_BYTES
254
+ body = +""
255
+ response.read_body do |chunk|
256
+ body << chunk
257
+ too_large! if body.bytesize > MAX_BODY_BYTES
258
+
259
+ # Shrink the socket's own timeout to what is left of the deadline, so
260
+ # a stalled body cannot outlive it.
261
+ apply_timeouts(http, deadline)
262
+ end
263
+ declared = response.content_length
264
+ if declared && body.bytesize != declared
265
+ raise ConnectionError, "The Fingerly API response was cut short"
266
+ end
267
+
268
+ result = Result.new(
269
+ status: response.code.to_i, headers: response.to_hash, body: body
270
+ )
271
+ end
272
+ result
273
+ end
274
+
275
+ def too_large!
276
+ raise Error, "The Fingerly API returned more than #{MAX_BODY_BYTES} bytes"
277
+ end
278
+
279
+ # @raise [TimeoutError] when nothing is left of the deadline
280
+ def apply_timeouts(http, deadline)
281
+ left = deadline - now
282
+ raise TimeoutError, "The Fingerly request timed out" if left <= 0
283
+
284
+ http.open_timeout = left
285
+ http.read_timeout = left
286
+ http.write_timeout = left
287
+ end
288
+
289
+ # @return [Array(Idle, Boolean)] a connection and whether it was reused
290
+ def connection
291
+ loop do
292
+ entry, stale = Transport.checkout(@pool_key, now)
293
+ stale.each { |old| discard(old.http) }
294
+ return [fresh, false] if entry.nil?
295
+ return [entry, true] if entry.http.started?
296
+
297
+ discard(entry.http)
298
+ end
299
+ end
300
+
301
+ def release(entry)
302
+ return discard(entry.http) unless entry.http.started?
303
+
304
+ entry.released_at = now
305
+ kept, stale = Transport.checkin(@pool_key, entry, now)
306
+ stale.each { |old| discard(old.http) }
307
+ discard(entry.http) unless kept
308
+ end
309
+
310
+ def fresh
311
+ Idle.new(build, now, now)
312
+ end
313
+
314
+ def build
315
+ # hostname, not host: host keeps an IPv6 literal's brackets, which
316
+ # Net::HTTP would then try to resolve as a name.
317
+ http = Net::HTTP.new(@uri.hostname, @uri.port)
318
+ http.use_ssl = @uri.scheme == "https"
319
+ http.max_retries = 0
320
+ # Net::HTTP accepts a body shorter than its content-length by default,
321
+ # which would pass a response cut short upstream on as complete.
322
+ http.ignore_eof = false
323
+ # Net::HTTP reconnects by itself after this long idle; keep it in step
324
+ # with the pool's own idea of a usable connection.
325
+ http.keep_alive_timeout = IDLE_TIMEOUT
326
+ @configure_http&.call(http)
327
+ http
328
+ end
329
+
330
+ def discard(http)
331
+ http.finish if http.started?
332
+ rescue IOError
333
+ # Already closed by the other end; nothing left to release.
334
+ end
335
+
336
+ def now
337
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
338
+ end
339
+
340
+ # Raised by the overall deadline; never escapes {#call}.
341
+ class DeadlineExceeded < StandardError; end
342
+ private_constant :DeadlineExceeded
343
+ end
344
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fingerly
4
+ # The SDK version, reported to the API on every request.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Fingerly
7
+ # Webhook signature verification.
8
+ #
9
+ # Stateless and module-level: verifying a delivery is one HMAC over bytes you
10
+ # already have, so it needs no client.
11
+ module Webhooks
12
+ # How far a delivery's timestamp may be from now, in seconds, before it is
13
+ # refused as a replay.
14
+ DEFAULT_TOLERANCE = 300
15
+
16
+ # A lowercase hex digest, exactly as Fingerly sends it. Anchored with \A
17
+ # and \z because Ruby's ^ and $ match at every line boundary.
18
+ SIGNATURE = /\Asha256=[a-f0-9]{64}\z/
19
+ # Whole unsigned seconds, and nothing else.
20
+ TIMESTAMP = /\A[0-9]+\z/
21
+
22
+ PREFIX = "sha256="
23
+
24
+ # Signatures read from one header. A rotation needs two; anything beyond
25
+ # this is not from Fingerly, and is not worth comparing one by one.
26
+ MAX_SIGNATURES = 32
27
+
28
+ module_function
29
+
30
+ # Reports whether a delivery came from Fingerly and is recent.
31
+ #
32
+ # payload = request.body.read
33
+ # ok = Fingerly::Webhooks.verify(
34
+ # secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"),
35
+ # payload: payload,
36
+ # timestamp: request.get_header("HTTP_X_FINGERLY_TIMESTAMP"),
37
+ # signature: request.get_header("HTTP_X_FINGERLY_SIGNATURE"),
38
+ # )
39
+ #
40
+ # Verification proves authenticity and freshness. It does not deduplicate,
41
+ # so persist processed {WebhookEvent#id} values as well.
42
+ #
43
+ # @param secret [String] the endpoint's signing secret
44
+ # @param payload [String] the exact bytes received, before any JSON
45
+ # parsing: re-serialising them changes the digest
46
+ # @param timestamp [String] the +x-fingerly-timestamp+ header
47
+ # @param signature [String] the +x-fingerly-signature+ header, which
48
+ # carries several comma-separated signatures during a secret rotation.
49
+ # Any one of the first {MAX_SIGNATURES} matching is accepted.
50
+ # @param tolerance [Numeric] the replay window in seconds
51
+ # @param now [Time, nil] the instant to measure freshness against.
52
+ # Override it only in tests.
53
+ # @return [Boolean] true when a signature matches and the timestamp is
54
+ # recent. Malformed or missing input returns false rather than raising.
55
+ def verify(secret:, payload:, timestamp:, signature:,
56
+ tolerance: DEFAULT_TOLERANCE, now: nil)
57
+ return false unless secret.is_a?(String) && !secret.empty?
58
+ return false unless payload.is_a?(String)
59
+ return false unless timestamp.is_a?(String) && signature.is_a?(String)
60
+
61
+ # Header values are whatever the sender wrote. As bytes, a value that is
62
+ # not valid in its encoding simply fails to match, where as a string it
63
+ # would raise from the regular expressions below.
64
+ timestamp = timestamp.b
65
+ signature = signature.b
66
+ # Check freshness before spending an HMAC on an obvious replay.
67
+ return false unless fresh?(timestamp, now, tolerance)
68
+
69
+ expected = sign(secret, timestamp, payload)
70
+ # The limit keeps a huge header from being split in full; whatever lies
71
+ # past the last candidate is ignored rather than compared.
72
+ candidates = signature.split(",", MAX_SIGNATURES + 1).first(MAX_SIGNATURES)
73
+ candidates.any? { |candidate| matches?(candidate, expected) }
74
+ end
75
+
76
+ # Parses a delivery's envelope.
77
+ #
78
+ # Verify the bytes before calling this: parsing an unverified body is
79
+ # trusting whoever sent it.
80
+ #
81
+ # @param payload [String] the raw delivery body
82
+ # @return [WebhookEvent]
83
+ # @raise [Error] if the body is not a Fingerly webhook envelope
84
+ def parse(payload)
85
+ parsed = JSON.parse(payload.to_s)
86
+ raise Error, "Not a Fingerly webhook envelope" unless parsed.is_a?(Hash)
87
+
88
+ WebhookEvent.new(parsed)
89
+ rescue JSON::ParserError
90
+ raise Error, "Not a Fingerly webhook envelope"
91
+ end
92
+
93
+ # Whether the signed time is within tolerance of now.
94
+ # @api private
95
+ def fresh?(timestamp, now, tolerance)
96
+ return false unless TIMESTAMP.match?(timestamp)
97
+ return false unless tolerance.is_a?(Numeric) && !tolerance.negative?
98
+
99
+ moment = (now || Time.now).to_i
100
+ (moment - Integer(timestamp, 10)).abs <= tolerance
101
+ end
102
+
103
+ # Computes the expected digest.
104
+ #
105
+ # Fingerly signs the timestamp, a literal dot, then the raw body bytes.
106
+ # Both are forced to binary first: a body that is not valid UTF-8 would
107
+ # otherwise raise on concatenation rather than simply fail to verify.
108
+ #
109
+ # @api private
110
+ def sign(secret, timestamp, payload)
111
+ signed = timestamp.b
112
+ signed += "."
113
+ signed += payload.b
114
+ OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
115
+ end
116
+
117
+ # Compares one candidate signature in constant time.
118
+ # @api private
119
+ def matches?(candidate, expected)
120
+ value = candidate.strip
121
+ return false unless SIGNATURE.match?(value)
122
+
123
+ OpenSSL.fixed_length_secure_compare(value.delete_prefix(PREFIX), expected)
124
+ end
125
+ end
126
+ end
data/lib/fingerly.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fingerly/version"
4
+ require_relative "fingerly/errors"
5
+ require_relative "fingerly/redacted"
6
+ require_relative "fingerly/config"
7
+ require_relative "fingerly/models"
8
+ require_relative "fingerly/transport"
9
+ require_relative "fingerly/client"
10
+ require_relative "fingerly/webhooks"
11
+ require_relative "fingerly/proxy"
12
+
13
+ # Server-side Fingerly SDK.
14
+ #
15
+ # It reads stored identification events with a secret key, verifies signed
16
+ # webhooks, and serves the browser SDK's requests from a route on your own
17
+ # domain. It has no runtime dependencies.
18
+ #
19
+ # require "fingerly"
20
+ #
21
+ # fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY"))
22
+ # event = fingerly.events.get(request_id)
23
+ #
24
+ # Secret keys and webhook secrets belong in server-side code only. A browser
25
+ # must never see either.
26
+ module Fingerly
27
+ end