verisec 1.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 +211 -0
- data/examples/quickstart.rb +32 -0
- data/lib/verisec/auth.rb +89 -0
- data/lib/verisec/client.rb +76 -0
- data/lib/verisec/errors.rb +125 -0
- data/lib/verisec/file_input.rb +125 -0
- data/lib/verisec/generated/resources.rb +1800 -0
- data/lib/verisec/generated/types.rb +4695 -0
- data/lib/verisec/image.rb +160 -0
- data/lib/verisec/multipart.rb +36 -0
- data/lib/verisec/resource.rb +66 -0
- data/lib/verisec/response_info.rb +34 -0
- data/lib/verisec/transport.rb +322 -0
- data/lib/verisec/util.rb +171 -0
- data/lib/verisec/verisec_object.rb +187 -0
- data/lib/verisec/version.rb +6 -0
- data/lib/verisec/webhook.rb +136 -0
- data/lib/verisec.rb +43 -0
- data/sig/generated/verisec.rbs +2212 -0
- data/sig/verisec.rbs +213 -0
- metadata +68 -0
data/lib/verisec/util.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# Encoding helpers shared by the transport and the resources.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Util
|
|
8
|
+
# Characters that stay as they are in a path segment (RFC 3986 "unreserved").
|
|
9
|
+
PATH_SAFE = /[^A-Za-z0-9\-._~]/.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# One path parameter, percent-encoded as a single segment. "." and ".."
|
|
14
|
+
# are encoded too, so nothing can read them as a step up the path.
|
|
15
|
+
#
|
|
16
|
+
# @param value [String, Symbol, Integer]
|
|
17
|
+
# @param name [String] the parameter's name, for the error message
|
|
18
|
+
# @return [String]
|
|
19
|
+
def path_segment(value, name)
|
|
20
|
+
text = value.is_a?(Symbol) || value.is_a?(Integer) ? value.to_s : value
|
|
21
|
+
unless text.is_a?(String) && !text.strip.empty?
|
|
22
|
+
raise ArgumentError, "#{name} must be a non-empty String, not #{value.inspect}"
|
|
23
|
+
end
|
|
24
|
+
return text.gsub(".", "%2E") if [".", ".."].include?(text)
|
|
25
|
+
|
|
26
|
+
text.b.gsub(PATH_SAFE) { |char| format("%%%02X", char.ord) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A query string: nil values are left out, an Array repeats its key.
|
|
30
|
+
#
|
|
31
|
+
# @param params [Hash]
|
|
32
|
+
# @return [String] without the leading "?"
|
|
33
|
+
def query_string(params)
|
|
34
|
+
pairs = []
|
|
35
|
+
params.each do |key, value|
|
|
36
|
+
next if value.nil?
|
|
37
|
+
|
|
38
|
+
if value.is_a?(Array)
|
|
39
|
+
value.each { |item| pairs << [key.to_s, scalar(item, key)] unless item.nil? }
|
|
40
|
+
else
|
|
41
|
+
pairs << [key.to_s, scalar(value, key)]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
URI.encode_www_form(pairs)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A copy of request parameters ready for the wire: keys as Strings, exactly
|
|
48
|
+
# as given (the API mixes `detectAll` and `image_front`, so key casing is
|
|
49
|
+
# never changed), nil values left out (no API field takes null), Symbols as
|
|
50
|
+
# Strings. Hashes and Arrays are copied, so the caller's input is never
|
|
51
|
+
# changed by what the library does to the copy.
|
|
52
|
+
#
|
|
53
|
+
# @param value [Object]
|
|
54
|
+
# @return [Object]
|
|
55
|
+
def wire(value)
|
|
56
|
+
case value
|
|
57
|
+
when Hash
|
|
58
|
+
value.each_with_object({}) { |(key, inner), out| out[key.to_s] = wire(inner) unless inner.nil? }
|
|
59
|
+
when Array then value.map { |inner| wire(inner) }
|
|
60
|
+
when Symbol then value.to_s
|
|
61
|
+
else value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Parses a response body: the JSON value, the text itself when it is not
|
|
66
|
+
# JSON (a proxy's error page), or nil when it is empty.
|
|
67
|
+
#
|
|
68
|
+
# @param text [String, nil]
|
|
69
|
+
# @return [Object, nil]
|
|
70
|
+
def parse_body(text)
|
|
71
|
+
return nil if text.nil? || text.empty?
|
|
72
|
+
|
|
73
|
+
text = text.dup.force_encoding(Encoding::UTF_8)
|
|
74
|
+
JSON.parse(text)
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
text.scrub
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# What a failed answer says, from any of the shapes the API, its
|
|
80
|
+
# validation layer and the gateway use: `{"error": {"code", "message",
|
|
81
|
+
# "param"}}`, `{"error": "…"}`, `{"detail": "…" | [{"loc", "msg"}]}`,
|
|
82
|
+
# `{"error_msg": "…"}`, or plain text.
|
|
83
|
+
#
|
|
84
|
+
# @param data [Object] the parsed body
|
|
85
|
+
# @return [Hash{Symbol => String, nil}] `code`, `message`, `param`
|
|
86
|
+
def error_body(data)
|
|
87
|
+
return { code: nil, message: data.empty? ? nil : data[0, 500], param: nil } if data.is_a?(String)
|
|
88
|
+
|
|
89
|
+
body = data.is_a?(Hash) ? data : {}
|
|
90
|
+
error = body["error"]
|
|
91
|
+
return { code: string(error["code"]), message: string(error["message"]), param: string(error["param"]) } if error.is_a?(Hash)
|
|
92
|
+
return { code: string(body["code"]), message: error, param: nil } if error.is_a?(String)
|
|
93
|
+
|
|
94
|
+
detail = body["detail"]
|
|
95
|
+
return { code: "invalid_input", message: detail, param: nil } if detail.is_a?(String)
|
|
96
|
+
if detail.is_a?(Array) && !detail.empty?
|
|
97
|
+
first = detail.first.is_a?(Hash) ? detail.first : {}
|
|
98
|
+
param = first["loc"].is_a?(Array) ? first["loc"].reject { |part| part == "body" }.join(".") : nil
|
|
99
|
+
return { code: "invalid_input", message: string(first["msg"]) || "The request is not valid.", param: presence(param) }
|
|
100
|
+
end
|
|
101
|
+
return { code: nil, message: body["error_msg"], param: nil } if body["error_msg"].is_a?(String)
|
|
102
|
+
|
|
103
|
+
{ code: nil, message: nil, param: nil }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Seconds from a header value such as Retry-After; nil unless it is a
|
|
107
|
+
# non-negative number.
|
|
108
|
+
#
|
|
109
|
+
# @param value [String, nil]
|
|
110
|
+
# @return [Integer, Float, nil]
|
|
111
|
+
def parse_seconds(value)
|
|
112
|
+
text = value.to_s.strip
|
|
113
|
+
seconds = Integer(text, 10, exception: false) || Float(text, exception: false)
|
|
114
|
+
seconds if seconds && seconds >= 0 && seconds.finite?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Compares two strings in constant time.
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def secure_compare(left, right)
|
|
121
|
+
left = left.b
|
|
122
|
+
right = right.b
|
|
123
|
+
return false unless left.bytesize == right.bytesize
|
|
124
|
+
return OpenSSL.fixed_length_secure_compare(left, right) if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
125
|
+
|
|
126
|
+
diff = 0
|
|
127
|
+
left.bytes.zip(right.bytes) { |a, b| diff |= a ^ b }
|
|
128
|
+
diff.zero?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The value as a String, or nil when it is nil or blank.
|
|
132
|
+
def presence(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
|
|
135
|
+
text = value.to_s
|
|
136
|
+
text.strip.empty? ? nil : text
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A value for an error message: at most `limit` characters.
|
|
140
|
+
def shorten(text, limit = 80)
|
|
141
|
+
text = text.to_s
|
|
142
|
+
text.length > limit ? "#{text[0, limit - 3]}..." : text
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Why a file could not be read, without the path the message repeats.
|
|
146
|
+
def reason(error)
|
|
147
|
+
error.is_a?(SystemCallError) ? error.message.split(" @ ").first : error.class.name
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A secret shown in #inspect: its prefix only.
|
|
151
|
+
def redact(secret)
|
|
152
|
+
return "nil" if secret.nil?
|
|
153
|
+
|
|
154
|
+
prefix = secret.to_s[/\A[a-z]+_(?:[a-z]+_)?/] || ""
|
|
155
|
+
"#{prefix}[REDACTED]"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def string(value)
|
|
159
|
+
value.is_a?(String) ? value : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def scalar(value, key)
|
|
163
|
+
case value
|
|
164
|
+
when String then value
|
|
165
|
+
when Hash, Array then raise ArgumentError, "#{key} can't be sent as a query parameter (#{value.class})"
|
|
166
|
+
else value.to_s
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :string, :scalar
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# A result the API returned: the KYC envelope's `data`, or a Financial object.
|
|
5
|
+
# Each answer type of the spec is a subclass in {VeriSec::Types} (generated),
|
|
6
|
+
# e.g. {VeriSec::Types::FaceVerify}; nested objects are wrapped too.
|
|
7
|
+
#
|
|
8
|
+
# Read fields as methods, or with `[]` (a String or a Symbol), under the
|
|
9
|
+
# API's own names (it mixes `detectAll` and `image_front`):
|
|
10
|
+
#
|
|
11
|
+
# result.match # => true
|
|
12
|
+
# result[:similarity] # => 0.98
|
|
13
|
+
# result["metadata"]["serverTime"] # => "2026-10-05T05:35:01.160Z"
|
|
14
|
+
# result.dig("compared", 0, "roi") # => [102, 87, 140, 140]
|
|
15
|
+
# result.last_response.request_id # => "req_…"
|
|
16
|
+
#
|
|
17
|
+
# A field the answer type defines but this answer left out reads as nil; a
|
|
18
|
+
# name the type does not have raises NoMethodError, so typos don't pass
|
|
19
|
+
# silently. Fields the API added after this version of the library are still
|
|
20
|
+
# readable. Names that are not Ruby method names ("Date of Birth") are read
|
|
21
|
+
# with `[]`.
|
|
22
|
+
#
|
|
23
|
+
# The "open" results (the engine's full answer: face match, document read,
|
|
24
|
+
# sanctions screening, …) define no fields; read them as a Hash, with `[]`,
|
|
25
|
+
# `dig`, `keys` and `to_h`.
|
|
26
|
+
#
|
|
27
|
+
# `to_h` gives the plain Hash, exactly the JSON as parsed (String keys),
|
|
28
|
+
# `to_json` the JSON. Results work in pattern matching:
|
|
29
|
+
#
|
|
30
|
+
# case result
|
|
31
|
+
# in {match: true, similarity: Float => similarity} then ...
|
|
32
|
+
# end
|
|
33
|
+
class VeriSecObject
|
|
34
|
+
# The fields this type defines: field name => the name of the type its
|
|
35
|
+
# value follows (a nested object, or an array's items), or nil. Generated
|
|
36
|
+
# subclasses fill it in; the base class (an object of no known type) has none.
|
|
37
|
+
FIELDS = {}.freeze
|
|
38
|
+
|
|
39
|
+
# The response this object came from, on the object an API call returned.
|
|
40
|
+
#
|
|
41
|
+
# @return [ResponseInfo, nil]
|
|
42
|
+
attr_reader :last_response
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Wraps parsed JSON: a Hash becomes an object of this type, an Array is
|
|
46
|
+
# wrapped item by item, anything else is returned as it is.
|
|
47
|
+
#
|
|
48
|
+
# @api private
|
|
49
|
+
# @param value [Object] parsed JSON
|
|
50
|
+
# @param last_response [ResponseInfo, nil]
|
|
51
|
+
# @return [Object]
|
|
52
|
+
def construct(value, last_response = nil)
|
|
53
|
+
case value
|
|
54
|
+
when Hash then new(value, last_response: last_response)
|
|
55
|
+
when Array then value.map { |item| construct(item) }
|
|
56
|
+
else value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Hash{String => String, nil}] see {FIELDS}
|
|
61
|
+
def fields
|
|
62
|
+
self::FIELDS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The class for a field's value.
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @return [Class<VeriSecObject>]
|
|
69
|
+
def field_type(key)
|
|
70
|
+
name = fields[key]
|
|
71
|
+
name ? Types.const_get(name, false) : VeriSecObject
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param values [Hash] the object's fields (String or Symbol keys)
|
|
76
|
+
# @param last_response [ResponseInfo, nil]
|
|
77
|
+
def initialize(values = {}, last_response: nil)
|
|
78
|
+
@values = {}
|
|
79
|
+
(values || {}).each do |key, value|
|
|
80
|
+
key = key.to_s
|
|
81
|
+
@values[key] = wrap(value, self.class.field_type(key))
|
|
82
|
+
end
|
|
83
|
+
@last_response = last_response
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param key [String, Symbol]
|
|
87
|
+
# @return [Object, nil] the field's value, or nil when it is absent
|
|
88
|
+
def [](key)
|
|
89
|
+
@values[key.to_s]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Like Hash#dig: `result.dig(:metadata, :ctx, :userIp)`.
|
|
93
|
+
#
|
|
94
|
+
# @return [Object, nil]
|
|
95
|
+
def dig(key, *rest)
|
|
96
|
+
value = self[key]
|
|
97
|
+
rest.empty? || value.nil? ? value : value.dig(*rest)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Like Hash#fetch: raises KeyError (or returns the default) when the field is absent.
|
|
101
|
+
#
|
|
102
|
+
# @return [Object]
|
|
103
|
+
def fetch(key, *default, &block)
|
|
104
|
+
@values.fetch(key.to_s, *default, &block)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] whether the answer has this field
|
|
108
|
+
def key?(key)
|
|
109
|
+
@values.key?(key.to_s)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Array<String>] the names of the fields this answer has, as the API spells them
|
|
113
|
+
def keys
|
|
114
|
+
@values.keys
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Hash{String => Object}] the plain Hash (nested objects converted too), as the API sent it
|
|
118
|
+
def to_h
|
|
119
|
+
@values.transform_values { |value| plain(value) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [String] the object as the API's JSON
|
|
123
|
+
def to_json(*args)
|
|
124
|
+
to_h.to_json(*args)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# For Rails and other JSON encoders.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash{String => Object}]
|
|
130
|
+
def as_json(*)
|
|
131
|
+
to_h
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Same type, same fields, same values (the response info is not compared).
|
|
135
|
+
def ==(other)
|
|
136
|
+
other.class == self.class && other.to_h == to_h
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# For pattern matching (`in {match: true}`).
|
|
140
|
+
#
|
|
141
|
+
# @return [Hash{Symbol => Object}]
|
|
142
|
+
def deconstruct_keys(_keys)
|
|
143
|
+
@values.transform_keys(&:to_sym)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [String] the fields as pretty-printed JSON
|
|
147
|
+
def to_s
|
|
148
|
+
JSON.pretty_generate(to_h)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def inspect
|
|
152
|
+
"#<#{self.class.name} #{JSON.generate(to_h)}>"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def method_missing(name, *args, &block)
|
|
158
|
+
if args.empty? && block.nil?
|
|
159
|
+
key = name.to_s
|
|
160
|
+
return @values[key] if @values.key?(key)
|
|
161
|
+
return nil if self.class.fields.key?(key)
|
|
162
|
+
end
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def respond_to_missing?(name, include_private = false)
|
|
167
|
+
key = name.to_s
|
|
168
|
+
@values.key?(key) || self.class.fields.key?(key) || super
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def wrap(value, type)
|
|
172
|
+
case value
|
|
173
|
+
when Hash then type.new(value)
|
|
174
|
+
when Array then value.map { |item| wrap(item, type) }
|
|
175
|
+
else value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def plain(value)
|
|
180
|
+
case value
|
|
181
|
+
when VeriSecObject then value.to_h
|
|
182
|
+
when Array then value.map { |item| plain(item) }
|
|
183
|
+
else value
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VeriSec
|
|
4
|
+
# Verifies webhook deliveries and parses their events. Works without a client
|
|
5
|
+
# (and is also `client.webhooks`).
|
|
6
|
+
#
|
|
7
|
+
# post "/webhooks/verisec" do
|
|
8
|
+
# payload = request.body.read
|
|
9
|
+
# event = VeriSec::Webhook.construct_event(payload, request.env, ENV["VERISEC_WEBHOOK_SECRET"])
|
|
10
|
+
# handle(event) if event.type == "verification.completed" # event.data
|
|
11
|
+
# status 200
|
|
12
|
+
# rescue VeriSec::SignatureVerificationError
|
|
13
|
+
# halt 400
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Deliveries are signed as Standard Webhooks: `webhook-id`,
|
|
17
|
+
# `webhook-timestamp` (unix seconds) and `webhook-signature`, a
|
|
18
|
+
# space-separated list of `v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<raw body>">`
|
|
19
|
+
# (several while a secret is rotating; any match passes). The key is the
|
|
20
|
+
# base64-decoded part of the endpoint secret after `whsec_`. The `svix-id`,
|
|
21
|
+
# `svix-timestamp` and `svix-signature` names work too.
|
|
22
|
+
#
|
|
23
|
+
# `headers` can be a Hash with any spelling of the names ("Webhook-Id",
|
|
24
|
+
# :webhook_id), or a Rack env (`request.env`, with `HTTP_WEBHOOK_ID`).
|
|
25
|
+
module Webhook
|
|
26
|
+
# Seconds a signed delivery stays valid, to stop replays.
|
|
27
|
+
DEFAULT_TOLERANCE = 300
|
|
28
|
+
|
|
29
|
+
# A webhook delivery's body: `type` (`verification.completed`, `aml.hit`,
|
|
30
|
+
# `credits.low`, …), `timestamp` (ISO 8601), `environment` (`test` or
|
|
31
|
+
# `live`) and `data`.
|
|
32
|
+
class Event < VeriSecObject
|
|
33
|
+
FIELDS = { "type" => nil, "timestamp" => nil, "environment" => nil, "data" => nil }.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Verifies the signature, then parses the event.
|
|
39
|
+
#
|
|
40
|
+
# Pass the body exactly as received (a String), before any JSON parsing.
|
|
41
|
+
#
|
|
42
|
+
# @param payload [String] the raw request body
|
|
43
|
+
# @param headers [Hash, #each] the delivery's headers (or a Rack env)
|
|
44
|
+
# @param secret [String] the endpoint's signing secret (`whsec_…`)
|
|
45
|
+
# @param tolerance [Integer] maximum distance from now in seconds; 0 skips the check
|
|
46
|
+
# @return [Event] `type`, `timestamp`, `environment`, `data`
|
|
47
|
+
# @raise [SignatureVerificationError] when no signature matches, a header is
|
|
48
|
+
# missing, or the timestamp is outside the tolerance
|
|
49
|
+
def construct_event(payload, headers, secret, tolerance: DEFAULT_TOLERANCE)
|
|
50
|
+
verify_signature!(payload, headers, secret, tolerance: tolerance)
|
|
51
|
+
body = JSON.parse(text(payload))
|
|
52
|
+
raise VeriSecError, "The webhook body is not a JSON object." unless body.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
Event.new(body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param (see construct_event)
|
|
58
|
+
# @return [Boolean] true when a signature matches and the timestamp is fresh
|
|
59
|
+
def verify_signature(payload, headers, secret, tolerance: DEFAULT_TOLERANCE)
|
|
60
|
+
verify_signature!(payload, headers, secret, tolerance: tolerance)
|
|
61
|
+
rescue SignatureVerificationError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Like {verify_signature}, but raises instead of returning false.
|
|
66
|
+
#
|
|
67
|
+
# @param (see construct_event)
|
|
68
|
+
# @return [true]
|
|
69
|
+
# @raise [SignatureVerificationError]
|
|
70
|
+
def verify_signature!(payload, headers, secret, tolerance: DEFAULT_TOLERANCE)
|
|
71
|
+
raise ArgumentError, "The webhook signing secret is missing." if secret.nil? || secret.to_s.empty?
|
|
72
|
+
|
|
73
|
+
id = header(headers, "webhook-id") || header(headers, "svix-id")
|
|
74
|
+
timestamp = header(headers, "webhook-timestamp") || header(headers, "svix-timestamp")
|
|
75
|
+
signatures = header(headers, "webhook-signature") || header(headers, "svix-signature")
|
|
76
|
+
unless id && timestamp && signatures
|
|
77
|
+
raise SignatureVerificationError, "Missing webhook-id, webhook-timestamp or webhook-signature header."
|
|
78
|
+
end
|
|
79
|
+
raise SignatureVerificationError, "Malformed webhook-timestamp header." unless timestamp.match?(/\A\d+\z/)
|
|
80
|
+
if tolerance.positive? && (Time.now.to_i - timestamp.to_i).abs > tolerance
|
|
81
|
+
raise SignatureVerificationError, "Timestamp is outside the #{tolerance} s tolerance."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
expected = sign(secret, id, timestamp, payload)
|
|
85
|
+
matched = signatures.split(" ").any? do |part|
|
|
86
|
+
version, value = part.split(",", 2)
|
|
87
|
+
version == "v1" && !value.to_s.empty? && Util.secure_compare(value, expected)
|
|
88
|
+
end
|
|
89
|
+
unless matched
|
|
90
|
+
raise SignatureVerificationError,
|
|
91
|
+
"No signature matches the payload. Check the signing secret and that the body is unmodified."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Valid headers for a payload, for your own tests.
|
|
98
|
+
#
|
|
99
|
+
# @param payload [String]
|
|
100
|
+
# @param secret [String] a `whsec_…` secret
|
|
101
|
+
# @param id [String, nil] the message id (a new `msg_…` when left out)
|
|
102
|
+
# @param timestamp [Integer] unix seconds
|
|
103
|
+
# @return [Hash{String => String}] `webhook-id`, `webhook-timestamp`, `webhook-signature`
|
|
104
|
+
def generate_test_headers(payload, secret, id: nil, timestamp: Time.now.to_i)
|
|
105
|
+
id ||= "msg_#{SecureRandom.hex(16)}"
|
|
106
|
+
timestamp = timestamp.to_i.to_s
|
|
107
|
+
{ "webhook-id" => id, "webhook-timestamp" => timestamp, "webhook-signature" => "v1,#{sign(secret, id, timestamp, payload)}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def sign(secret, id, timestamp, payload)
|
|
111
|
+
secret = secret.to_s
|
|
112
|
+
key = (secret.start_with?("whsec_") ? secret.delete_prefix("whsec_") : secret).unpack1("m")
|
|
113
|
+
[OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.".b + payload.to_s.b)].pack("m0")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A header by its lower-case name, from a Hash of any spelling or a Rack env.
|
|
117
|
+
def header(headers, name)
|
|
118
|
+
return nil if headers.nil?
|
|
119
|
+
|
|
120
|
+
wanted = name.tr("-", "_")
|
|
121
|
+
headers.each do |key, value|
|
|
122
|
+
normalized = key.to_s.downcase.tr("-", "_").delete_prefix("http_")
|
|
123
|
+
next unless normalized == wanted
|
|
124
|
+
|
|
125
|
+
value = value.first if value.is_a?(Array)
|
|
126
|
+
return value.to_s.strip unless value.nil?
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def text(payload)
|
|
132
|
+
payload.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
133
|
+
end
|
|
134
|
+
private_class_method :sign, :header, :text
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/verisec.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "zlib"
|
|
10
|
+
|
|
11
|
+
require_relative "verisec/version"
|
|
12
|
+
|
|
13
|
+
# The official Ruby library for the VeriSec API: identity (KYC) checks and the
|
|
14
|
+
# Financial cheque, card, bank and BIN reads.
|
|
15
|
+
#
|
|
16
|
+
# require "verisec"
|
|
17
|
+
#
|
|
18
|
+
# client = VeriSec::Client.new(api_key: ENV["VERISEC_API_KEY"]) # sk_test_… or ak_live_…
|
|
19
|
+
# result = client.face.verify(image1: "selfie.jpg", image2: "id-photo.jpg")
|
|
20
|
+
# result.match # => true
|
|
21
|
+
# result.last_response.request_id # => "req_…"
|
|
22
|
+
#
|
|
23
|
+
# See {VeriSec::Client} for the calls, {VeriSec::Webhook} for webhook
|
|
24
|
+
# deliveries and {VeriSec::VeriSecError} for what can go wrong.
|
|
25
|
+
module VeriSec
|
|
26
|
+
# Where requests go unless the client (or `VERISEC_BASE_URL`) says otherwise.
|
|
27
|
+
DEFAULT_BASE_URL = "https://verisec.mmose.me"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
require_relative "verisec/errors"
|
|
31
|
+
require_relative "verisec/util"
|
|
32
|
+
require_relative "verisec/response_info"
|
|
33
|
+
require_relative "verisec/verisec_object"
|
|
34
|
+
require_relative "verisec/image"
|
|
35
|
+
require_relative "verisec/file_input"
|
|
36
|
+
require_relative "verisec/multipart"
|
|
37
|
+
require_relative "verisec/webhook"
|
|
38
|
+
require_relative "verisec/auth"
|
|
39
|
+
require_relative "verisec/transport"
|
|
40
|
+
require_relative "verisec/resource"
|
|
41
|
+
require_relative "verisec/generated/types"
|
|
42
|
+
require_relative "verisec/generated/resources"
|
|
43
|
+
require_relative "verisec/client"
|