catalisa-biometrics 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cfbf89065beb0aa0cb605efab699bab5458db396c1e356df40195b8d52aed522
4
+ data.tar.gz: 23acfaded0c4209a537b34f31d2e4d85ce4d88d3d780ac7d193aeda44861c7a7
5
+ SHA512:
6
+ metadata.gz: aa549767800b3f096b122c74f6528d0dcaaa250d8277ef16ef0f3ddb77e46355ce3197c8d9c89b837bf6e23d4c2e564b1e2bc4af2f27f23db97dfb9ee42ff5b7
7
+ data.tar.gz: b4029c5653c1104c4d02385841c2464d26830c533a93f3179c4e3dcd0e9624984ad58a464edecda4a193bea6579614255eaf6bad691e2e44c998303679d7a3f6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Catalisa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # catalisa-biometrics
2
+
3
+ Ruby SDK for **Catalisa Biometrics** — liveness and face-match sessions, signed evidence and webhook verification.
4
+
5
+ - Standard library only: `net/http`, `openssl`, `json`. Ruby **3.0+**
6
+ - One typed error carrying the API's own code
7
+ - Offline verification of the Ed25519 evidence signature
8
+
9
+ ```bash
10
+ gem install catalisa-biometrics
11
+ ```
12
+
13
+ ## How the flow works
14
+
15
+ 1. **Your server** creates a session and receives `handoff.captureUrl`.
16
+ 2. The person opens that URL (browser tab, iframe or WebView) and does the challenge.
17
+ 3. The decision arrives **on your server** through the `biometrics.session.completed` webhook, or through `#session`.
18
+
19
+ The capture page never reveals the result to the person in front of the camera — on purpose, so it cannot be used as a fraud oracle. Never decide anything from front-end events.
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ require "catalisa/biometrics"
25
+
26
+ client = Catalisa::Biometrics::Client.new(api_key: ENV.fetch("CATALISA_API_KEY"))
27
+
28
+ session = client.create_session(
29
+ flow: Catalisa::Biometrics::Flows::LIVENESS_ONLY,
30
+ purpose: "abertura de conta",
31
+ metadata: { orderId: "123" },
32
+ )
33
+ # send the person to session.dig("handoff", "captureUrl")
34
+
35
+ envelope = client.session(session["sessionId"])
36
+ # proceed when envelope["status"] == "APPROVED"
37
+ ```
38
+
39
+ ## API
40
+
41
+ | Method | What it does |
42
+ |---|---|
43
+ | `create_session` | Opens a session; the envelope comes with `handoff` |
44
+ | `session` | Reads the session, with decision, checks and evidence |
45
+ | `sessions` | Lists sessions, newest first, with filters and pagination |
46
+ | `cancel_session` | Closes a session that has not settled; the allowance slot comes back |
47
+ | `evidence` | Signed evidence plus short-lived download links |
48
+ | `verify_evidence_on_server` | Asks the API to check the signature |
49
+ | `evidence_keys` | Published signing keys, retired ones included |
50
+ | `Evidence.verify` | Checks the signature **offline**, without calling Catalisa |
51
+ | `submit_capture` | Uploads a capture recorded by your own app |
52
+ | `WebhookVerifier#verify` | Verifies a delivery and returns the parsed event |
53
+
54
+ ## Errors
55
+
56
+ Every failure raises `Catalisa::Biometrics::Error` with the API's own code:
57
+
58
+ ```ruby
59
+ begin
60
+ session = client.create_session(flow: "LIVENESS_ONLY", purpose: "kyc")
61
+ rescue Catalisa::Biometrics::Error => e
62
+ if e.quota_exceeded?
63
+ # the customer's monthly allowance is gone (402); e.details has the numbers
64
+ elsif e.subaccount_suspended?
65
+ # suspended: nothing opens until it is reactivated
66
+ elsif e.transient?
67
+ # rate limit, server fault or network: worth trying again
68
+ end
69
+ warn "#{e.code}: #{e.message} (request #{e.request_id})"
70
+ end
71
+ ```
72
+
73
+ ## Retries
74
+
75
+ - **429** is retried for every method, honoring `Retry-After`: the rate limiter refuses before anything runs.
76
+ - **5xx and network failures** are retried only for reads.
77
+ - **Session creation is never retried** on those: the server does not deduplicate by `Idempotency-Key` yet, and a retry could open two sessions (and spend the allowance twice).
78
+
79
+ ## Webhooks
80
+
81
+ The delivery is signed with the subscription's **private** key; you verify with the public one. Verify the **raw** bytes — parsing and re-serializing the JSON changes them and the signature will not match.
82
+
83
+ ```ruby
84
+ verifier = Catalisa::Biometrics::WebhookVerifier.new(public_keys_by_key_id)
85
+
86
+ post "/webhooks/biometrics" do
87
+ raw = request.body.read
88
+ begin
89
+ event = verifier.verify(request.env, raw)
90
+ rescue Catalisa::Biometrics::WebhookVerificationError
91
+ halt 400
92
+ end
93
+ # event["id"] is stable: use it for idempotency, a delivery can repeat
94
+ status 200 # answer fast; do the work afterwards
95
+ end
96
+ ```
97
+
98
+ It reads headers in whatever shape your stack hands them over, Rack's `HTTP_X_WEBHOOK_ID` included. The engine does not refuse old deliveries — the receiver does, with a five-minute tolerance by default.
99
+
100
+ ## Signed evidence
101
+
102
+ ```ruby
103
+ keys = client.evidence_keys
104
+ evidence = client.evidence(session_id)
105
+ out = Catalisa::Biometrics::Evidence.verify(session_id, envelope["attempt"], evidence["bundleHash"], evidence["signature"], keys)
106
+ # out[:valid] says whether this is the bundle Catalisa signed
107
+ ```
108
+
109
+ The message is `sessionId|attempt|bundleHash|signedAt`, signed with Ed25519. Retired keys stay published, so evidence signed years ago still verifies.
110
+
111
+ ## Test environment
112
+
113
+ An API key belongs to one world and says so on every session it opens:
114
+
115
+ | | `test` (sandbox) | `live` |
116
+ |---|---|---|
117
+ | Engine | Simulated, deterministic | The real one configured for the account |
118
+ | Result | Decided by the CPF ending in `subject_ref` | Decided by the engine |
119
+ | Allowance | Not spent | Spent |
120
+ | Billing | Never billed | Billed |
121
+ | Visibility | A test key only reads test sessions; a live key only reads live ones | |
122
+
123
+ A test key needs no engine configured, so you can integrate on day one. The envelope and the `session.completed` payload carry `environment`.
124
+
125
+ Endings that drive the sandbox result: `…-25` (or any other) approves, `…-11` comes back inconclusive for human review, `…-55` asks for a second attempt and then approves, `…-66` fails as an engine error, `…-00`, `…-33` and `…-44` reject by face match, liveness and continuity.
126
+
127
+ ## Subaccounts
128
+
129
+ With an organization key, pass `subaccount_id:` to act on behalf of a subaccount; with a subaccount key the scope is already bound. Envelopes and webhook payloads carry `subaccountId` (`nil` for organization sessions).
130
+
131
+ ## Custom capture
132
+
133
+ `submit_capture` uploads a video your own app recorded, authenticating with the session's `captureToken` — the account key never leaves your server. Only `video/webm` and `video/mp4` are accepted, up to 16 MiB and 24 seconds.
134
+
135
+ Prefer the hosted page when you can: it already runs the quality checks that keep a useless recording from spending an attempt.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ ruby -Ilib -Itest -e 'Dir.glob("test/*_test.rb").each { |f| require File.expand_path(f) }'
141
+ ```
142
+
143
+ The tests share `vectors/vectors.json` with the Node, Python, Go, PHP and .NET SDKs — the same signatures, produced by the building block's own code.
144
+
145
+ Releases go out through GitHub Actions with RubyGems trusted publishing: no API key is stored anywhere.
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Catalisa
9
+ module Biometrics
10
+ DEFAULT_BASE_URL = "https://api.biometrics.catalisa.app/v1"
11
+
12
+ # Sandbox or production. A key belongs to one; a session keeps the one it was
13
+ # born in.
14
+ module Environments
15
+ TEST = "test"
16
+ LIVE = "live"
17
+ end
18
+
19
+ module Flows
20
+ ONBOARDING = "ONBOARDING"
21
+ AUTHENTICATION = "AUTHENTICATION"
22
+ ENROLLMENT = "ENROLLMENT"
23
+ LIVENESS_ONLY = "LIVENESS_ONLY"
24
+ DEDUP = "DEDUP"
25
+ end
26
+
27
+ module Statuses
28
+ SESSION_OPEN = "SESSION_OPEN"
29
+ PROCESSING = "PROCESSING"
30
+ APPROVED = "APPROVED"
31
+ REJECTED = "REJECTED"
32
+ INCONCLUSIVE = "INCONCLUSIVE"
33
+ RETRY_ALLOWED = "RETRY_ALLOWED"
34
+ EXPIRED = "EXPIRED"
35
+ CANCELLED = "CANCELLED"
36
+ ERROR = "ERROR"
37
+
38
+ TERMINAL = [APPROVED, REJECTED, INCONCLUSIVE, EXPIRED, CANCELLED].freeze
39
+
40
+ # The session has settled: nothing will change after this.
41
+ def self.terminal?(status) = TERMINAL.include?(status)
42
+ end
43
+
44
+ # Catalisa Biometrics client.
45
+ #
46
+ # The flow never trusts the browser:
47
+ #
48
+ # 1. your server creates a session and gets handoff.captureUrl;
49
+ # 2. the person opens that URL (tab, iframe or WebView) and does the challenge;
50
+ # 3. the decision reaches your server through the webhook, or through #session.
51
+ #
52
+ # The capture page never shows the result to the person in front of the
53
+ # camera, on purpose: it would be a fraud oracle. Never decide anything from
54
+ # front-end events.
55
+ class Client
56
+ # @param api_key [String] key from the console; a test key runs the sandbox
57
+ # @param access_token [String, nil] an IAM JWT instead of an API key
58
+ # @param subaccount_id [String, nil] act on behalf of a subaccount (organization key only)
59
+ # @param timeout [Numeric] seconds per attempt
60
+ # @param max_retries [Integer] reads and 429 only
61
+ # @param transport [#call, nil] ->(request) { { status:, headers:, body: } }, for tests
62
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, access_token: nil, subaccount_id: nil,
63
+ timeout: 30, max_retries: 2, transport: nil)
64
+ if (api_key.nil? || api_key.empty?) && (access_token.nil? || access_token.empty?)
65
+ raise ArgumentError, "Catalisa Biometrics: an API key (or an access token) is required"
66
+ end
67
+
68
+ @api_key = api_key
69
+ @access_token = access_token
70
+ @base_url = base_url.chomp("/")
71
+ @subaccount_id = subaccount_id
72
+ @timeout = timeout
73
+ @max_retries = max_retries
74
+ @transport = transport
75
+ end
76
+
77
+ # Opens a session. The answer carries handoff.captureUrl, where the person goes.
78
+ #
79
+ # Never retried on a 5xx or a network failure: the server does not
80
+ # deduplicate by Idempotency-Key yet, and a retry could open two sessions
81
+ # (and spend the allowance twice).
82
+ def create_session(flow:, purpose:, idempotency_key: nil, **fields)
83
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
84
+ body = { flow: flow, purpose: purpose }.merge(fields)
85
+
86
+ request(:post, "/sessions", body: body, headers: headers).fetch("data", {})
87
+ end
88
+
89
+ # Reads a session. One from another subaccount — or from the other
90
+ # environment — answers 404, on purpose: existence is not confirmed.
91
+ def session(session_id)
92
+ request(:get, "/sessions/#{escape(session_id)}", idempotent: true).fetch("data", {})
93
+ end
94
+
95
+ # Lists sessions, newest first.
96
+ def sessions(page: nil, page_size: nil, **filters)
97
+ query = {}
98
+ query["page[number]"] = page if page
99
+ query["page[size]"] = page_size if page_size
100
+ %i[status flow customer_id subject_cpf from to subaccount_id environment].each do |key|
101
+ value = filters[key]
102
+ next if value.nil? || value.to_s.empty?
103
+
104
+ query[camel(key)] = value
105
+ end
106
+
107
+ request(:get, "/sessions", query: query, idempotent: true)
108
+ end
109
+
110
+ # Closes a session that has not settled. The allowance slot comes back.
111
+ def cancel_session(session_id)
112
+ request(:post, "/sessions/#{escape(session_id)}/cancel").fetch("data", {})
113
+ end
114
+
115
+ # Signed evidence plus short-lived download links for the artifacts.
116
+ def evidence(session_id)
117
+ request(:get, "/sessions/#{escape(session_id)}/evidence", idempotent: true).fetch("data", {})
118
+ end
119
+
120
+ # Asks the API to check the evidence signature. Handy, but it trusts the
121
+ # same party that signed it: for an audit, verify offline with
122
+ # Catalisa::Biometrics::Evidence.verify.
123
+ def verify_evidence_on_server(session_id)
124
+ request(:get, "/sessions/#{escape(session_id)}/evidence/verify", idempotent: true).fetch("data", {})
125
+ end
126
+
127
+ # Published signing keys, retired ones included, so old evidence still verifies.
128
+ def evidence_keys
129
+ request(:get, "/evidence-keys", idempotent: true).fetch("data", [])
130
+ end
131
+
132
+ # Uploads a capture your own app recorded, authenticating with the session's
133
+ # capture token — the account key never leaves your server. Only video/webm
134
+ # and video/mp4 are accepted, up to 16 MiB and 24 seconds.
135
+ #
136
+ # Prefer the hosted page when you can: it already runs the quality checks
137
+ # that keep a useless recording from spending an attempt.
138
+ def submit_capture(session_id, capture_token, video:, video_mime: "video/webm",
139
+ video_filename: "challenge.webm", telemetry: nil, channel: nil, captured_at: nil)
140
+ raise Error.new(status: 0, code: "UNAUTHORIZED", message: "a capture token is required") if capture_token.to_s.empty?
141
+ raise Error.new(status: 0, code: "VALIDATION", message: "the video is empty") if video.to_s.empty?
142
+
143
+ boundary = "----catalisa#{SecureRandom.hex(8)}"
144
+ parts = +"--#{boundary}\r\n"
145
+ parts << "Content-Disposition: form-data; name=\"video\"; filename=\"#{video_filename}\"\r\n"
146
+ parts << "Content-Type: #{video_mime}\r\n\r\n"
147
+ parts << video.dup.force_encoding(Encoding::BINARY)
148
+ parts << "\r\n"
149
+ { "telemetry" => telemetry && JSON.generate(telemetry), "channel" => channel, "capturedAt" => captured_at }
150
+ .each do |name, value|
151
+ next if value.nil?
152
+
153
+ parts << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{name}\"\r\n\r\n#{value}\r\n"
154
+ end
155
+ parts << "--#{boundary}--\r\n"
156
+
157
+ answer = send_request(
158
+ method: :post,
159
+ url: "#{@base_url}/sessions/#{escape(session_id)}/captures",
160
+ # The capture token authenticates on its own; the API key is not sent.
161
+ headers: {
162
+ "Accept" => "application/json",
163
+ "User-Agent" => user_agent,
164
+ "Authorization" => "Bearer #{capture_token}",
165
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}",
166
+ },
167
+ body: parts,
168
+ )
169
+
170
+ decode(answer).fetch("data", {})
171
+ end
172
+
173
+ private
174
+
175
+ def user_agent = "catalisa-biometrics-ruby/#{VERSION}"
176
+
177
+ def escape(value) = URI.encode_www_form_component(value.to_s)
178
+
179
+ def camel(key)
180
+ head, *rest = key.to_s.split("_")
181
+ ([head] + rest.map(&:capitalize)).join
182
+ end
183
+
184
+ def request(method, path, query: {}, body: nil, headers: {}, idempotent: false)
185
+ url = "#{@base_url}#{path}"
186
+ url += "?#{URI.encode_www_form(query)}" unless query.empty?
187
+
188
+ request_headers = { "Accept" => "application/json", "User-Agent" => user_agent }.merge(headers)
189
+ if @access_token && !@access_token.empty?
190
+ request_headers["Authorization"] = "Bearer #{@access_token}"
191
+ else
192
+ request_headers["X-API-Key"] = @api_key
193
+ end
194
+ request_headers["X-Subaccount-Id"] = @subaccount_id if @subaccount_id && !@subaccount_id.empty?
195
+ payload = body && JSON.generate(body)
196
+ request_headers["Content-Type"] = "application/json" if payload
197
+
198
+ attempt = 0
199
+ begin
200
+ decode(send_request(method: method, url: url, headers: request_headers, body: payload))
201
+ rescue Error => e
202
+ # A 429 is always safe to retry: the rate limiter refuses before the
203
+ # handler runs, so nothing was done.
204
+ retryable = e.status == 429 || (idempotent && (e.status >= 500 || e.status.zero?))
205
+ raise unless retryable && attempt < @max_retries
206
+
207
+ sleep(backoff(attempt, e))
208
+ attempt += 1
209
+ retry
210
+ end
211
+ end
212
+
213
+ def backoff(attempt, error)
214
+ return [error.retry_after, 30].min if error.retry_after&.positive?
215
+
216
+ base = 0.5 * (2**attempt)
217
+ [base + rand * base * 0.25, 8.0].min
218
+ end
219
+
220
+ def decode(answer)
221
+ status = answer[:status]
222
+ raise Error.from_response(status, answer[:body], answer[:headers]) if status >= 400
223
+ return {} if answer[:body].to_s.empty?
224
+
225
+ JSON.parse(answer[:body])
226
+ rescue JSON::ParserError => e
227
+ raise Error.new(status: status, code: "INTERNAL", message: "unexpected answer: #{e.message}")
228
+ end
229
+
230
+ def send_request(method:, url:, headers:, body: nil)
231
+ return @transport.call(method: method, url: url, headers: headers, body: body) if @transport
232
+
233
+ uri = URI(url)
234
+ klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete }.fetch(method)
235
+ request = klass.new(uri, headers)
236
+ request.body = body if body
237
+
238
+ response = Net::HTTP.start(uri.host, uri.port,
239
+ use_ssl: uri.scheme == "https",
240
+ open_timeout: @timeout,
241
+ read_timeout: @timeout) { |http| http.request(request) }
242
+
243
+ { status: response.code.to_i, headers: response.each_header.to_h, body: response.body }
244
+ rescue Net::OpenTimeout, Net::ReadTimeout
245
+ raise Error.new(status: 0, code: "TIMEOUT", message: "no answer within #{@timeout}s")
246
+ rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
247
+ raise Error.new(status: 0, code: "CONNECTION", message: "connection failed: #{e.message}")
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catalisa
4
+ module Biometrics
5
+ # Every failed call raises this. The API answers
6
+ # `{"error":"<code>","message":"...","details":{...}}`, so #code is the API's
7
+ # own word for what happened — branch on it, never on the message.
8
+ class Error < StandardError
9
+ # HTTP status, or 0 when the request never reached the API.
10
+ attr_reader :status
11
+
12
+ # API error code (VALIDATION, NOT_FOUND, QUOTA_EXCEEDED…), or
13
+ # TIMEOUT/CONNECTION when the request did not complete.
14
+ attr_reader :code
15
+
16
+ # Whatever the API attached: the offending fields, the quota numbers.
17
+ attr_reader :details
18
+
19
+ # `x-request-id`, when the API sends one — quote it in a support ticket.
20
+ attr_reader :request_id
21
+
22
+ # `Retry-After` in seconds, on a 429.
23
+ attr_reader :retry_after
24
+
25
+ def initialize(status:, code:, message:, details: {}, request_id: nil, retry_after: nil)
26
+ super(message)
27
+ @status = status
28
+ @code = code
29
+ @details = details
30
+ @request_id = request_id
31
+ @retry_after = retry_after
32
+ end
33
+
34
+ # The customer's monthly allowance is gone. A test key never gets here.
35
+ def quota_exceeded? = code == "QUOTA_EXCEEDED"
36
+
37
+ # The subaccount is suspended: nothing opens until it is reactivated.
38
+ def subaccount_suspended? = code == "SUBACCOUNT_SUSPENDED"
39
+
40
+ # Worth trying again by itself: rate limit, server fault or network.
41
+ def transient? = status == 429 || status >= 500 || status.zero?
42
+
43
+ # Builds the error from what the API answered. A body that is not the
44
+ # expected envelope — an HTML page from a proxy, say — still yields a usable
45
+ # code, taken from the status.
46
+ def self.from_response(status, body, headers)
47
+ parsed = begin
48
+ body.to_s.empty? ? nil : JSON.parse(body)
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ code = code_for(status)
54
+ message = ""
55
+ details = {}
56
+
57
+ if parsed.is_a?(Hash)
58
+ code = parsed["error"] if parsed["error"].is_a?(String) && !parsed["error"].empty?
59
+ message = parsed["message"] if parsed["message"].is_a?(String)
60
+ if parsed["details"].is_a?(Hash)
61
+ details = parsed["details"]
62
+ # The machine-readable code lives inside details on the cases that
63
+ # carry numbers with them (quota, suspension).
64
+ code = details["code"] if details["code"].is_a?(String) && !details["code"].empty?
65
+ end
66
+ end
67
+ message = message_for(status) if message.empty?
68
+
69
+ new(
70
+ status: status,
71
+ code: code,
72
+ message: message,
73
+ details: details,
74
+ request_id: headers["x-request-id"],
75
+ retry_after: headers["retry-after"]&.to_i,
76
+ )
77
+ end
78
+
79
+ def self.code_for(status)
80
+ case status
81
+ when 400, 415 then "VALIDATION"
82
+ when 401 then "UNAUTHORIZED"
83
+ when 402 then "QUOTA_EXCEEDED"
84
+ when 403 then "FORBIDDEN"
85
+ when 404 then "NOT_FOUND"
86
+ when 409 then "CONFLICT"
87
+ when 413 then "PAYLOAD_TOO_LARGE"
88
+ when 429 then "RATE_LIMITED"
89
+ when 500.. then "INTERNAL"
90
+ else "ERROR"
91
+ end
92
+ end
93
+ private_class_method :code_for
94
+
95
+ def self.message_for(status)
96
+ case status
97
+ when 401 then "API key missing, wrong or revoked"
98
+ when 402 then "monthly allowance exhausted"
99
+ when 403 then "this key may not do that"
100
+ when 404 then "not found in this scope"
101
+ when 429 then "too many requests"
102
+ when 500.. then "the API failed"
103
+ else "request failed with status #{status}"
104
+ end
105
+ end
106
+ private_class_method :message_for
107
+ end
108
+
109
+ # A webhook delivery that failed verification. Answer 400 and do not process
110
+ # the body.
111
+ class WebhookVerificationError < StandardError
112
+ # Which check failed: :missing_headers, :invalid_signature,
113
+ # :timestamp_out_of_tolerance, :unknown_key_id, :unsupported_signature_version.
114
+ attr_reader :failure
115
+
116
+ def initialize(failure, message)
117
+ super(message)
118
+ @failure = failure
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catalisa
4
+ module Biometrics
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "time"
7
+
8
+ module Catalisa
9
+ module Biometrics
10
+ # Verification of Catalisa Webhooks Engine deliveries:
11
+ #
12
+ # message = x-webhook-id + "\n" + x-webhook-timestamp + "\n" + raw body
13
+ # signature = RSA-SHA256 (PKCS#1 v1.5), base64, in x-webhook-signature as "v1=<base64>"
14
+ # key = the subscription's PUBLIC key, chosen by x-webhook-key-id
15
+ #
16
+ # The signature is asymmetric: there is no shared secret to leak. The engine
17
+ # does not refuse old deliveries — the receiver enforces the time tolerance,
18
+ # or a captured delivery could be replayed forever.
19
+ class WebhookVerifier
20
+ MESSAGES = {
21
+ missing_headers: "missing x-webhook-id, x-webhook-timestamp, x-webhook-key-id or x-webhook-signature",
22
+ timestamp_invalid: "x-webhook-timestamp is not an ISO 8601 date",
23
+ timestamp_out_of_tolerance: "delivery is outside the time tolerance (possible replay)",
24
+ unknown_key_id: "no public key for this x-webhook-key-id",
25
+ unsupported_signature_version: "unsupported signature version (expected v1=)",
26
+ invalid_signature: "invalid signature",
27
+ }.freeze
28
+
29
+ # @param public_keys [Hash{String=>String}] keyId => PEM. Keep a retired key
30
+ # here while deliveries signed with it may still arrive.
31
+ # @param tolerance [Integer] seconds; 0 disables the check (not recommended)
32
+ # @param now [Proc] reference clock, for tests
33
+ def initialize(public_keys, tolerance: 300, now: -> { Time.now })
34
+ @public_keys = public_keys
35
+ @tolerance = tolerance
36
+ @now = now
37
+ end
38
+
39
+ # Verifies a delivery and returns the parsed event.
40
+ #
41
+ # Pass the RAW body — the bytes as they arrived. Decoding and re-encoding
42
+ # the JSON changes them, and the signature will not match.
43
+ #
44
+ # @param headers [Hash] however your stack hands them over: Rack's
45
+ # HTTP_X_WEBHOOK_ID, a framework's x-webhook-id, arrays included
46
+ def verify(headers, raw_body)
47
+ id = header(headers, "x-webhook-id")
48
+ timestamp = header(headers, "x-webhook-timestamp")
49
+ key_id = header(headers, "x-webhook-key-id")
50
+ signature = header(headers, "x-webhook-signature")
51
+
52
+ fail_with(:missing_headers) if [id, timestamp, key_id, signature].any? { |v| v.nil? || v.empty? }
53
+
54
+ sent_at = begin
55
+ Time.iso8601(timestamp)
56
+ rescue ArgumentError
57
+ fail_with(:timestamp_invalid)
58
+ end
59
+ fail_with(:timestamp_out_of_tolerance) if @tolerance.positive? && (@now.call - sent_at).abs > @tolerance
60
+ fail_with(:unsupported_signature_version) unless signature.start_with?("v1=")
61
+
62
+ pem = @public_keys[key_id]
63
+ fail_with(:unknown_key_id) if pem.nil?
64
+
65
+ raw = Base64.strict_decode64(signature[3..]) rescue fail_with(:invalid_signature)
66
+ key = begin
67
+ OpenSSL::PKey::RSA.new(pem)
68
+ rescue OpenSSL::PKey::RSAError
69
+ fail_with(:unknown_key_id)
70
+ end
71
+
72
+ message = "#{id}\n#{timestamp}\n#{raw_body}"
73
+ fail_with(:invalid_signature) unless key.verify(OpenSSL::Digest.new("SHA256"), raw, message)
74
+
75
+ begin
76
+ JSON.parse(raw_body)
77
+ rescue JSON::ParserError => e
78
+ fail_with(:invalid_signature, "the signed body is not valid JSON: #{e.message}")
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def header(headers, name)
85
+ headers.each do |key, value|
86
+ normalized = key.to_s.downcase.tr("_", "-")
87
+ normalized = normalized.delete_prefix("http-")
88
+ next unless normalized == name
89
+
90
+ found = value.is_a?(Array) ? value.first : value
91
+ return found.nil? || found.to_s.empty? ? nil : found.to_s
92
+ end
93
+ nil
94
+ end
95
+
96
+ def fail_with(failure, message = nil)
97
+ raise WebhookVerificationError.new(failure, message || MESSAGES.fetch(failure))
98
+ end
99
+ end
100
+
101
+ # OFFLINE verification of the signed evidence, without calling Catalisa:
102
+ #
103
+ # message = "{sessionId}|{attempt}|{bundleHash}|{signedAt}"
104
+ # signature = Ed25519 (RFC 8032), base64
105
+ # key = GET /evidence-keys, by the signature's keyId
106
+ #
107
+ # This is what lets a third party — an auditor, a court — confirm years later
108
+ # that the bundle is the one Catalisa signed.
109
+ module Evidence
110
+ module_function
111
+
112
+ # The exact string that was signed.
113
+ def message(session_id, attempt, bundle_hash, signed_at)
114
+ "#{session_id}|#{attempt}|#{bundle_hash}|#{signed_at}"
115
+ end
116
+
117
+ # @return [Hash] valid:, key_id:, unknown_key:, retired_at:
118
+ def verify(session_id, attempt, bundle_hash, signature, keys)
119
+ key_id = signature["keyId"] || signature[:keyId]
120
+ key = keys.find { |k| (k["keyId"] || k[:keyId]) == key_id }
121
+ return { valid: false, key_id: key_id, unknown_key: true, retired_at: nil } if key.nil?
122
+
123
+ retired_at = key["retiredAt"] || key[:retiredAt]
124
+ out = { valid: false, key_id: key_id, unknown_key: false, retired_at: retired_at }
125
+ return out unless (signature["alg"] || signature[:alg]) == "Ed25519"
126
+
127
+ raw = Base64.strict_decode64((signature["value"] || signature[:value]).to_s) rescue (return out)
128
+ public_key = begin
129
+ OpenSSL::PKey.read(key["publicKeyPem"] || key[:publicKeyPem])
130
+ rescue OpenSSL::PKey::PKeyError
131
+ return out
132
+ end
133
+ # Ed25519 signs the message itself, with no separate digest step: hence the
134
+ # nil digest. Not verify_raw — that one answers false for Ed25519 instead
135
+ # of raising, which is a quiet way to reject every valid receipt.
136
+ signed = message(session_id, attempt, bundle_hash, signature["signedAt"] || signature[:signedAt])
137
+ out[:valid] = public_key.verify(nil, raw, signed)
138
+
139
+ out
140
+ rescue OpenSSL::PKey::PKeyError
141
+ out
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "biometrics/version"
4
+ require_relative "biometrics/errors"
5
+ require_relative "biometrics/client"
6
+ require_relative "biometrics/webhooks"
7
+
8
+ # Catalisa Biometrics: liveness and face-match sessions, signed evidence and
9
+ # webhook verification. Standard library only.
10
+ module Catalisa
11
+ module Biometrics
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: catalisa-biometrics
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Catalisa
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Ruby client for Catalisa Biometrics: opens verification sessions, reads
14
+ the decision with its score and threshold, verifies the signed receipt offline and
15
+ the webhook signature. Standard library only.'
16
+ email:
17
+ - contato@catalisa.app
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/catalisa/biometrics.rb
25
+ - lib/catalisa/biometrics/client.rb
26
+ - lib/catalisa/biometrics/errors.rb
27
+ - lib/catalisa/biometrics/version.rb
28
+ - lib/catalisa/biometrics/webhooks.rb
29
+ homepage: https://docs.catalisa.app
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ source_code_uri: https://github.com/catalisaio/catalisa-biometrics-sdk/tree/main/packages/ruby
34
+ bug_tracker_uri: https://github.com/catalisaio/catalisa-biometrics-sdk/issues
35
+ documentation_uri: https://docs.catalisa.app/blocks/biometrics/
36
+ rubygems_mfa_required: 'true'
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.0'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.5.22
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: Catalisa Biometrics SDK — liveness and face-match sessions, signed evidence
56
+ and webhook verification
57
+ test_files: []