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 +7 -0
- data/LICENSE +21 -0
- data/README.md +149 -0
- data/lib/catalisa/biometrics/client.rb +251 -0
- data/lib/catalisa/biometrics/errors.rb +122 -0
- data/lib/catalisa/biometrics/version.rb +7 -0
- data/lib/catalisa/biometrics/webhooks.rb +145 -0
- data/lib/catalisa/biometrics.rb +13 -0
- metadata +57 -0
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,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: []
|