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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VeriSec
4
+ # The gem's version: the first release of the API as the gateway serves it.
5
+ VERSION = "1.0.0"
6
+ 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"