didit-sdk 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.
Files changed (234) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/lib/didit/authentication/base64_url.rb +34 -0
  6. data/lib/didit/authentication/cache.rb +201 -0
  7. data/lib/didit/authentication/http.rb +150 -0
  8. data/lib/didit/authentication/issuer.rb +115 -0
  9. data/lib/didit/authentication/jws.rb +231 -0
  10. data/lib/didit/authentication/key_set.rb +89 -0
  11. data/lib/didit/authentication/pkce.rb +64 -0
  12. data/lib/didit/authentication/types.rb +175 -0
  13. data/lib/didit/authentication.rb +546 -0
  14. data/lib/didit/client.rb +211 -0
  15. data/lib/didit/errors.rb +153 -0
  16. data/lib/didit/file_part.rb +85 -0
  17. data/lib/didit/internal/config.rb +94 -0
  18. data/lib/didit/internal/errors.rb +83 -0
  19. data/lib/didit/internal/log.rb +41 -0
  20. data/lib/didit/internal/multipart.rb +60 -0
  21. data/lib/didit/internal/request.rb +322 -0
  22. data/lib/didit/internal/transport.rb +198 -0
  23. data/lib/didit/internal/util.rb +76 -0
  24. data/lib/didit/internal.rb +15 -0
  25. data/lib/didit/model.rb +199 -0
  26. data/lib/didit/page.rb +130 -0
  27. data/lib/didit/raw_response.rb +57 -0
  28. data/lib/didit/resource.rb +15 -0
  29. data/lib/didit/resources/account.rb +309 -0
  30. data/lib/didit/resources/address_validation.rb +73 -0
  31. data/lib/didit/resources/age_estimation.rb +110 -0
  32. data/lib/didit/resources/aml.rb +166 -0
  33. data/lib/didit/resources/aml_monitoring.rb +30 -0
  34. data/lib/didit/resources/analytics.rb +87 -0
  35. data/lib/didit/resources/bank.rb +65 -0
  36. data/lib/didit/resources/billing.rb +61 -0
  37. data/lib/didit/resources/biometric_templates.rb +234 -0
  38. data/lib/didit/resources/blocklist.rb +130 -0
  39. data/lib/didit/resources/business_sessions.rb +29 -0
  40. data/lib/didit/resources/businesses.rb +471 -0
  41. data/lib/didit/resources/cases.rb +2116 -0
  42. data/lib/didit/resources/customization.rb +153 -0
  43. data/lib/didit/resources/database_validation.rb +327 -0
  44. data/lib/didit/resources/document_ai.rb +145 -0
  45. data/lib/didit/resources/email.rb +254 -0
  46. data/lib/didit/resources/face_match.rb +103 -0
  47. data/lib/didit/resources/face_search.rb +103 -0
  48. data/lib/didit/resources/fraud_checks.rb +130 -0
  49. data/lib/didit/resources/id_verification.rb +163 -0
  50. data/lib/didit/resources/id_wallets.rb +1468 -0
  51. data/lib/didit/resources/investigator.rb +419 -0
  52. data/lib/didit/resources/ip.rb +68 -0
  53. data/lib/didit/resources/kyb.rb +256 -0
  54. data/lib/didit/resources/lists.rb +332 -0
  55. data/lib/didit/resources/monitoring_events.rb +466 -0
  56. data/lib/didit/resources/networks.rb +427 -0
  57. data/lib/didit/resources/passive_liveness.rb +103 -0
  58. data/lib/didit/resources/phone.rb +271 -0
  59. data/lib/didit/resources/pricing.rb +40 -0
  60. data/lib/didit/resources/proof_of_address.rb +167 -0
  61. data/lib/didit/resources/questionnaires.rb +170 -0
  62. data/lib/didit/resources/regulatory_reports.rb +314 -0
  63. data/lib/didit/resources/report_templates.rb +146 -0
  64. data/lib/didit/resources/sessions.rb +2820 -0
  65. data/lib/didit/resources/transactions.rb +981 -0
  66. data/lib/didit/resources/travel_rule.rb +1202 -0
  67. data/lib/didit/resources/users.rb +521 -0
  68. data/lib/didit/resources/wallet_screening.rb +190 -0
  69. data/lib/didit/resources/webhooks.rb +396 -0
  70. data/lib/didit/resources/workflows.rb +214 -0
  71. data/lib/didit/resources.rb +263 -0
  72. data/lib/didit/types/account.rb +11 -0
  73. data/lib/didit/types/account_components.rb +105 -0
  74. data/lib/didit/types/address_validation.rb +8 -0
  75. data/lib/didit/types/age_estimation.rb +89 -0
  76. data/lib/didit/types/aml.rb +193 -0
  77. data/lib/didit/types/aml_monitoring.rb +73 -0
  78. data/lib/didit/types/analytics.rb +62 -0
  79. data/lib/didit/types/bank.rb +8 -0
  80. data/lib/didit/types/billing.rb +18 -0
  81. data/lib/didit/types/biometric_templates.rb +292 -0
  82. data/lib/didit/types/blocklist.rb +31 -0
  83. data/lib/didit/types/businesses.rb +45 -0
  84. data/lib/didit/types/cases.rb +368 -0
  85. data/lib/didit/types/components.rb +12206 -0
  86. data/lib/didit/types/customization.rb +8 -0
  87. data/lib/didit/types/database_validation.rb +177 -0
  88. data/lib/didit/types/document_ai.rb +135 -0
  89. data/lib/didit/types/email.rb +116 -0
  90. data/lib/didit/types/face_match.rb +93 -0
  91. data/lib/didit/types/face_search.rb +129 -0
  92. data/lib/didit/types/fraud_checks.rb +8 -0
  93. data/lib/didit/types/id_verification.rb +231 -0
  94. data/lib/didit/types/id_wallets.rb +8 -0
  95. data/lib/didit/types/investigator.rb +221 -0
  96. data/lib/didit/types/ip.rb +8 -0
  97. data/lib/didit/types/kyb.rb +1391 -0
  98. data/lib/didit/types/lists.rb +111 -0
  99. data/lib/didit/types/monitoring_events.rb +235 -0
  100. data/lib/didit/types/networks.rb +120 -0
  101. data/lib/didit/types/passive_liveness.rb +95 -0
  102. data/lib/didit/types/phone.rb +69 -0
  103. data/lib/didit/types/proof_of_address.rb +166 -0
  104. data/lib/didit/types/questionnaires.rb +29 -0
  105. data/lib/didit/types/regulatory_reports.rb +100 -0
  106. data/lib/didit/types/report_templates.rb +41 -0
  107. data/lib/didit/types/sessions.rb +1441 -0
  108. data/lib/didit/types/transactions.rb +245 -0
  109. data/lib/didit/types/travel_rule.rb +168 -0
  110. data/lib/didit/types/users.rb +180 -0
  111. data/lib/didit/types/wallet_screening.rb +49 -0
  112. data/lib/didit/types/webhooks.rb +355 -0
  113. data/lib/didit/types/workflows.rb +32 -0
  114. data/lib/didit/types.rb +1681 -0
  115. data/lib/didit/version.rb +5 -0
  116. data/lib/didit/webhooks/canonical_json.rb +187 -0
  117. data/lib/didit/webhooks/client_methods.rb +23 -0
  118. data/lib/didit/webhooks/events.rb +221 -0
  119. data/lib/didit/webhooks/headers.rb +83 -0
  120. data/lib/didit/webhooks/verifier.rb +260 -0
  121. data/lib/didit/webhooks.rb +99 -0
  122. data/lib/didit-sdk.rb +4 -0
  123. data/lib/didit.rb +31 -0
  124. data/sig/didit/authentication/base64_url.rbs +10 -0
  125. data/sig/didit/authentication/cache.rbs +60 -0
  126. data/sig/didit/authentication/http.rbs +37 -0
  127. data/sig/didit/authentication/issuer.rbs +17 -0
  128. data/sig/didit/authentication/jws.rbs +41 -0
  129. data/sig/didit/authentication/jwt.rbs +14 -0
  130. data/sig/didit/authentication/key_set.rbs +25 -0
  131. data/sig/didit/authentication/pkce.rbs +20 -0
  132. data/sig/didit/authentication/types.rbs +114 -0
  133. data/sig/didit/authentication.rbs +81 -0
  134. data/sig/didit/client.rbs +61 -0
  135. data/sig/didit/errors.rbs +80 -0
  136. data/sig/didit/file_part.rbs +19 -0
  137. data/sig/didit/internal.rbs +199 -0
  138. data/sig/didit/model.rbs +42 -0
  139. data/sig/didit/page.rbs +34 -0
  140. data/sig/didit/raw_response.rbs +20 -0
  141. data/sig/didit/resource.rbs +8 -0
  142. data/sig/didit/resources/account.rbs +23 -0
  143. data/sig/didit/resources/address_validation.rbs +9 -0
  144. data/sig/didit/resources/age_estimation.rbs +9 -0
  145. data/sig/didit/resources/aml.rbs +10 -0
  146. data/sig/didit/resources/aml_monitoring.rbs +9 -0
  147. data/sig/didit/resources/analytics.rbs +9 -0
  148. data/sig/didit/resources/bank.rbs +9 -0
  149. data/sig/didit/resources/billing.rbs +10 -0
  150. data/sig/didit/resources/biometric_templates.rbs +13 -0
  151. data/sig/didit/resources/blocklist.rbs +11 -0
  152. data/sig/didit/resources/business_sessions.rbs +9 -0
  153. data/sig/didit/resources/businesses.rbs +22 -0
  154. data/sig/didit/resources/cases.rbs +103 -0
  155. data/sig/didit/resources/customization.rbs +10 -0
  156. data/sig/didit/resources/database_validation.rbs +9 -0
  157. data/sig/didit/resources/document_ai.rbs +9 -0
  158. data/sig/didit/resources/email.rbs +11 -0
  159. data/sig/didit/resources/face_match.rbs +9 -0
  160. data/sig/didit/resources/face_search.rbs +9 -0
  161. data/sig/didit/resources/fraud_checks.rbs +11 -0
  162. data/sig/didit/resources/id_verification.rbs +9 -0
  163. data/sig/didit/resources/id_wallets.rbs +37 -0
  164. data/sig/didit/resources/investigator.rbs +23 -0
  165. data/sig/didit/resources/ip.rbs +9 -0
  166. data/sig/didit/resources/kyb.rbs +12 -0
  167. data/sig/didit/resources/lists.rbs +20 -0
  168. data/sig/didit/resources/monitoring_events.rbs +16 -0
  169. data/sig/didit/resources/networks.rbs +25 -0
  170. data/sig/didit/resources/passive_liveness.rbs +9 -0
  171. data/sig/didit/resources/phone.rbs +11 -0
  172. data/sig/didit/resources/pricing.rbs +9 -0
  173. data/sig/didit/resources/proof_of_address.rbs +9 -0
  174. data/sig/didit/resources/questionnaires.rbs +13 -0
  175. data/sig/didit/resources/regulatory_reports.rbs +16 -0
  176. data/sig/didit/resources/report_templates.rbs +13 -0
  177. data/sig/didit/resources/sessions.rbs +78 -0
  178. data/sig/didit/resources/transactions.rbs +43 -0
  179. data/sig/didit/resources/travel_rule.rbs +62 -0
  180. data/sig/didit/resources/users.rbs +21 -0
  181. data/sig/didit/resources/wallet_screening.rbs +11 -0
  182. data/sig/didit/resources/webhooks.rbs +23 -0
  183. data/sig/didit/resources/workflows.rbs +13 -0
  184. data/sig/didit/resources.rbs +50 -0
  185. data/sig/didit/types/account.rbs +22 -0
  186. data/sig/didit/types/account_components.rbs +94 -0
  187. data/sig/didit/types/address_validation.rbs +7 -0
  188. data/sig/didit/types/age_estimation.rbs +80 -0
  189. data/sig/didit/types/aml.rbs +174 -0
  190. data/sig/didit/types/aml_monitoring.rbs +67 -0
  191. data/sig/didit/types/analytics.rbs +57 -0
  192. data/sig/didit/types/bank.rbs +9 -0
  193. data/sig/didit/types/billing.rbs +19 -0
  194. data/sig/didit/types/biometric_templates.rbs +268 -0
  195. data/sig/didit/types/blocklist.rbs +33 -0
  196. data/sig/didit/types/businesses.rbs +54 -0
  197. data/sig/didit/types/cases.rbs +414 -0
  198. data/sig/didit/types/components.rbs +10920 -0
  199. data/sig/didit/types/customization.rbs +7 -0
  200. data/sig/didit/types/database_validation.rbs +152 -0
  201. data/sig/didit/types/document_ai.rbs +123 -0
  202. data/sig/didit/types/email.rbs +112 -0
  203. data/sig/didit/types/face_match.rbs +86 -0
  204. data/sig/didit/types/face_search.rbs +112 -0
  205. data/sig/didit/types/fraud_checks.rbs +9 -0
  206. data/sig/didit/types/id_verification.rbs +196 -0
  207. data/sig/didit/types/id_wallets.rbs +55 -0
  208. data/sig/didit/types/investigator.rbs +212 -0
  209. data/sig/didit/types/ip.rbs +7 -0
  210. data/sig/didit/types/kyb.rbs +1254 -0
  211. data/sig/didit/types/lists.rbs +106 -0
  212. data/sig/didit/types/monitoring_events.rbs +234 -0
  213. data/sig/didit/types/networks.rbs +113 -0
  214. data/sig/didit/types/passive_liveness.rbs +87 -0
  215. data/sig/didit/types/phone.rbs +65 -0
  216. data/sig/didit/types/proof_of_address.rbs +144 -0
  217. data/sig/didit/types/questionnaires.rbs +30 -0
  218. data/sig/didit/types/regulatory_reports.rbs +101 -0
  219. data/sig/didit/types/report_templates.rbs +42 -0
  220. data/sig/didit/types/sessions.rbs +1349 -0
  221. data/sig/didit/types/transactions.rbs +273 -0
  222. data/sig/didit/types/travel_rule.rbs +188 -0
  223. data/sig/didit/types/users.rbs +181 -0
  224. data/sig/didit/types/wallet_screening.rbs +40 -0
  225. data/sig/didit/types/webhooks.rbs +311 -0
  226. data/sig/didit/types/workflows.rbs +30 -0
  227. data/sig/didit/webhooks/canonical_json.rbs +25 -0
  228. data/sig/didit/webhooks/client_methods.rbs +11 -0
  229. data/sig/didit/webhooks/events.rbs +125 -0
  230. data/sig/didit/webhooks/headers.rbs +22 -0
  231. data/sig/didit/webhooks/verifier.rbs +54 -0
  232. data/sig/didit/webhooks.rbs +37 -0
  233. data/sig/didit.rbs +20 -0
  234. metadata +300 -0
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Didit
7
+ module Webhooks
8
+ # One verification (docs/DESIGN.md section 9.3). {Didit::Webhooks.verify} and
9
+ # {Didit::Webhooks.verify_parsed} are the public entry points.
10
+ #
11
+ # @api private
12
+ class Verifier
13
+ # Signatures over the whole body, in the order they are tried for each secret.
14
+ FULL_BODY = [
15
+ HEADER_SIGNATURE, HEADER_SIGNATURE_V2, HEADER_SIGNATURE_PREVIOUS, HEADER_SIGNATURE_V2_PREVIOUS
16
+ ].freeze
17
+ # Signatures of the canonical JSON, the only ones a parsed body can be checked against.
18
+ CANONICAL = [HEADER_SIGNATURE_V2, HEADER_SIGNATURE_V2_PREVIOUS].freeze
19
+ # The deprecated envelope-only signatures.
20
+ SIMPLE = [HEADER_SIGNATURE_SIMPLE, HEADER_SIGNATURE_SIMPLE_PREVIOUS].freeze
21
+ ALL = (FULL_BODY + SIMPLE).freeze
22
+ # The fields X-Signature-Simple signs, in order.
23
+ SIMPLE_FIELDS = %w[timestamp session_id status webhook_type].freeze
24
+
25
+ # Timestamps at or beyond this many seconds from 1970 (16 digits) are never in tolerance.
26
+ MAX_TIMESTAMP = 1_000_000_000_000_000
27
+ # X-Timestamp: an optional sign and digits (leading zeros allowed), at most 32 characters.
28
+ TIMESTAMP_HEADER = /\A[+-]?[0-9]{1,31}\z/
29
+ # Strict JSON (no NaN). A repeated key keeps its last value, like Python. json 2.13 added
30
+ # allow_duplicate_key for that (without it json 2.13+ warns and json 3 raises); older
31
+ # versions do not know the option and keep the last value anyway.
32
+ DUPLICATE_KEY_OPTION = (JSON::VERSION.split(".").map(&:to_i) <=> [2, 13]).to_i >= 0
33
+ PARSE_OPTIONS = (DUPLICATE_KEY_OPTION ? { allow_nan: false, allow_duplicate_key: true } : { allow_nan: false })
34
+ .freeze
35
+
36
+ # @raise [Didit::ConfigurationError] for a missing or empty secret, a bad tolerance or clock,
37
+ # or headers that cannot be read
38
+ def initialize(secret, headers, tolerance:, now:)
39
+ @keys = secret_keys(secret)
40
+ @tolerance = check_tolerance(tolerance)
41
+ @now = check_now(now)
42
+ @headers = Headers.new(headers)
43
+ @raw = nil
44
+ @payload = nil
45
+ @digests = {}
46
+ end
47
+
48
+ # @return [Event]
49
+ def verify(raw_body, allow_simple:)
50
+ # Only true turns the envelope-only signature on: "false" from an environment variable must not.
51
+ unless [true, false].include?(allow_simple)
52
+ raise ConfigurationError, "allow_simple must be true or false, got #{allow_simple.inspect}."
53
+ end
54
+
55
+ raw = body_bytes(raw_body)
56
+ @raw = raw
57
+ @payload = normalize(parse_json(raw))
58
+ verified_by = check_signatures(allow_simple ? FULL_BODY + SIMPLE : FULL_BODY)
59
+ Event.build(payload, verified_by)
60
+ end
61
+
62
+ # @return [Event]
63
+ def verify_parsed(payload)
64
+ unless payload.is_a?(Hash)
65
+ raise failure(:invalid_payload,
66
+ "verify_parsed needs the parsed JSON object (a Hash), not a #{payload.class}; " \
67
+ "pass the raw body String to verify instead.")
68
+ end
69
+
70
+ @payload = normalize(payload)
71
+ Event.build(self.payload, check_signatures(CANONICAL))
72
+ end
73
+
74
+ private
75
+
76
+ # The parsed body, a Hash with String keys.
77
+ def payload
78
+ @payload || raise(ArgumentError, "no payload")
79
+ end
80
+
81
+ def secret_keys(secret)
82
+ if secret.nil?
83
+ raise ConfigurationError, "No webhook secret (nil); copy it from the webhook destination in the " \
84
+ "Didit console, for example into DIDIT_WEBHOOK_SECRET."
85
+ end
86
+ list = secret.is_a?(Array) ? secret : [secret]
87
+ raise ConfigurationError, "No webhook secret (an empty list)." if list.empty?
88
+
89
+ list.map { |item| key_bytes(item) }
90
+ end
91
+
92
+ def key_bytes(secret)
93
+ unless secret.is_a?(String)
94
+ raise ConfigurationError, "The webhook secret must be a String or an Array of Strings."
95
+ end
96
+ raise ConfigurationError, "The webhook secret is empty." if secret.empty?
97
+
98
+ (secret.encoding == Encoding::BINARY ? secret : secret.encode(Encoding::UTF_8)).b
99
+ rescue EncodingError
100
+ raise ConfigurationError, "The webhook secret cannot be converted to UTF-8."
101
+ end
102
+
103
+ def check_tolerance(tolerance)
104
+ number = tolerance.is_a?(Integer) || tolerance.is_a?(Float)
105
+ # `>= 0` is false for NaN, which would otherwise switch the replay check off.
106
+ return tolerance if number && tolerance >= 0
107
+
108
+ raise ConfigurationError, "tolerance must be a number of seconds, 0 or more."
109
+ end
110
+
111
+ def check_now(now)
112
+ return Time.now.to_f if now.nil?
113
+ return now if now.is_a?(Integer) || (now.is_a?(Float) && now.finite?)
114
+
115
+ raise ConfigurationError, "now must be unix seconds, an Integer or a Float."
116
+ end
117
+
118
+ def body_bytes(raw_body)
119
+ return raw_body.b if raw_body.is_a?(String)
120
+
121
+ raise failure(:invalid_payload, "The webhook body must be the raw request body as a String, " \
122
+ "not a #{raw_body.class}.")
123
+ end
124
+
125
+ # Every parser failure is invalid_payload, whatever the JSON library raises.
126
+ def parse_json(bytes)
127
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
128
+ raise failure(:invalid_payload, "The webhook body is not valid UTF-8.") unless text.valid_encoding?
129
+
130
+ value = begin
131
+ JSON.parse(text, **PARSE_OPTIONS)
132
+ rescue StandardError # JSON::ParserError, JSON::NestingError, EncodingError, ...
133
+ raise failure(:invalid_payload, "The webhook body is not valid JSON.")
134
+ end
135
+ raise failure(:invalid_payload, "The webhook body is not a JSON object.") unless value.is_a?(Hash)
136
+
137
+ value
138
+ end
139
+
140
+ def normalize(value)
141
+ CanonicalJson.normalize(value)
142
+ rescue ArgumentError => e
143
+ raise failure(:invalid_payload, "The webhook payload is not JSON data Didit sends: #{e.message}.")
144
+ end
145
+
146
+ # Steps 3 to 9 of the rules: timestamp, then the signatures in order.
147
+ def check_signatures(checked)
148
+ check_timestamp
149
+ unless ALL.any? { |name| @headers.present?(name) }
150
+ raise failure(:missing_signature,
151
+ "The request has no Didit signature header (X-Signature, X-Signature-V2 or " \
152
+ "X-Signature-Simple).")
153
+ end
154
+
155
+ match(checked) || raise(no_match(checked))
156
+ end
157
+
158
+ def check_timestamp
159
+ timestamp = payload["timestamp"]
160
+ unless timestamp.is_a?(Integer)
161
+ raise failure(:missing_timestamp, "The webhook body has no integer \"timestamp\".")
162
+ end
163
+
164
+ stale = timestamp >= MAX_TIMESTAMP || timestamp <= -MAX_TIMESTAMP
165
+ check_timestamp_header(timestamp) unless stale
166
+ return unless stale || (@now - timestamp).abs > @tolerance
167
+
168
+ raise failure(:timestamp_out_of_tolerance,
169
+ "The webhook timestamp is more than #{@tolerance} seconds away from now: a delayed or " \
170
+ "replayed delivery, or a clock that is off.")
171
+ end
172
+
173
+ def check_timestamp_header(timestamp)
174
+ header = @headers.timestamp
175
+ return if header.nil? || (header.match?(TIMESTAMP_HEADER) && Integer(header, 10) == timestamp)
176
+
177
+ raise failure(:timestamp_mismatch, "X-Timestamp does not match the \"timestamp\" in the webhook body.")
178
+ end
179
+
180
+ # The first header, for the first secret, whose signature matches; nil when none does.
181
+ def match(checked)
182
+ full = checked & FULL_BODY
183
+ simple = checked & SIMPLE
184
+ @keys.each do |key|
185
+ full.each { |name| return name if signed?(key, name) }
186
+ end
187
+ return if simple.empty? || simple_message.nil?
188
+
189
+ @keys.each do |key|
190
+ simple.each { |name| return name if signed?(key, name) }
191
+ end
192
+ nil
193
+ end
194
+
195
+ def signed?(key, name)
196
+ candidates = @headers.signatures(name)
197
+ return false if candidates.empty?
198
+
199
+ expected = digest(key, name)
200
+ candidates.any? { |candidate| OpenSSL.fixed_length_secure_compare(expected, candidate) }
201
+ end
202
+
203
+ # The HMAC of what +name+ signs; a header and its -Previous variant share it.
204
+ def digest(key, name)
205
+ kind = message_kind(name)
206
+ @digests[[key, kind]] ||= OpenSSL::HMAC.hexdigest("SHA256", key, message(kind))
207
+ end
208
+
209
+ def message_kind(name)
210
+ return :canonical if CANONICAL.include?(name)
211
+
212
+ SIMPLE.include?(name) ? :simple : :raw
213
+ end
214
+
215
+ def message(kind)
216
+ case kind
217
+ when :raw then @raw || raise(ArgumentError, "no raw body")
218
+ when :canonical then canonical
219
+ else simple_message || raise(ArgumentError, "no simple message")
220
+ end
221
+ end
222
+
223
+ # Built only when an X-Signature-V2 value is well formed and nothing matched before it.
224
+ def canonical
225
+ @canonical ||= CanonicalJson.dump(payload)
226
+ end
227
+
228
+ # "{timestamp}:{session_id}:{status}:{webhook_type}" with Python's str() of each value;
229
+ # nil when one of them is an object or an array, which never matches.
230
+ def simple_message
231
+ return @simple_message if defined?(@simple_message)
232
+
233
+ parts = SIMPLE_FIELDS.map { |field| payload.key?(field) ? CanonicalJson.python_str(payload[field]) : "" }
234
+ @simple_message = parts.all? ? parts.join(":") : nil
235
+ end
236
+
237
+ def no_match(checked)
238
+ present = ALL.select { |name| @headers.present?(name) }
239
+ message =
240
+ if checked == CANONICAL && !present.intersect?(CANONICAL)
241
+ "verify_parsed can check only X-Signature-V2, and the request has none; pass the raw body to verify."
242
+ elsif checked == CANONICAL
243
+ "X-Signature-V2 does not match: check that the secret belongs to this destination and that the " \
244
+ "payload is the body as parsed, unchanged."
245
+ elsif !present.intersect?(checked)
246
+ "The request carries only X-Signature-Simple, which does not cover the body and counts only " \
247
+ "with allow_simple: true."
248
+ else
249
+ "No webhook signature matches the secret: check that the secret belongs to this destination and " \
250
+ "that the raw body reaches verify unchanged."
251
+ end
252
+ failure(:invalid_signature, message)
253
+ end
254
+
255
+ def failure(reason, message)
256
+ WebhookVerificationError.new(reason, message)
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Didit
4
+ # Verify Didit webhook deliveries and read them as typed events (docs/DESIGN.md section 9).
5
+ #
6
+ # event = Didit::Webhooks.verify(request.raw_post, request.headers, ENV.fetch("DIDIT_WEBHOOK_SECRET"))
7
+ # case event
8
+ # in Didit::Webhooks::StatusUpdatedEvent(status: "Approved", session_id:) then approve(session_id)
9
+ # in Didit::Webhooks::UnknownEvent then nil # a type this SDK version does not know yet
10
+ # else nil
11
+ # end
12
+ #
13
+ # The verifier needs no client, no API key and no framework; +examples/webhooks/+ has Rails,
14
+ # Sinatra and plain Rack receivers. The same methods are on +client.webhooks+.
15
+ module Webhooks
16
+ # Default maximum distance in seconds between the delivery's timestamp and now.
17
+ DEFAULT_TOLERANCE = 300
18
+
19
+ HEADER_SIGNATURE = "X-Signature"
20
+ HEADER_SIGNATURE_V2 = "X-Signature-V2"
21
+ HEADER_SIGNATURE_SIMPLE = "X-Signature-Simple"
22
+ HEADER_SIGNATURE_PREVIOUS = "X-Signature-Previous"
23
+ HEADER_SIGNATURE_V2_PREVIOUS = "X-Signature-V2-Previous"
24
+ HEADER_SIGNATURE_SIMPLE_PREVIOUS = "X-Signature-Simple-Previous"
25
+ HEADER_TIMESTAMP = "X-Timestamp"
26
+
27
+ class << self
28
+ # Verifies a webhook delivery and returns its typed event.
29
+ #
30
+ # Pass the exact bytes of the request body (+request.raw_post+, +request.body.read+).
31
+ # The delivery is accepted when, for one of the secrets, +X-Signature+ matches the body,
32
+ # +X-Signature-V2+ matches its canonical JSON (it survives middleware that re-encodes the
33
+ # JSON), or one of their +-Previous+ variants matches (secret rotation). The deprecated
34
+ # +X-Signature-Simple+ counts only with +allow_simple: true+, and then
35
+ # {Event#body_authenticated?} is false: re-fetch the record from the API.
36
+ #
37
+ # @param raw_body [String] the request body as received, in any encoding (read as UTF-8)
38
+ # @param headers [Hash, #each] request headers: a Hash (names in any case), a Rack env,
39
+ # Rails +request.headers+, or [name, value] pairs
40
+ # @param secret [String, Array<String>] the destination's secret, or several during a rotation
41
+ # @param tolerance [Integer, Float] largest accepted distance in seconds between the
42
+ # delivery's timestamp and now
43
+ # @param allow_simple [Boolean] also accept the envelope-only +X-Signature-Simple+
44
+ # @param now [Integer, Float, nil] the current unix time, for tests
45
+ # @return [Event]
46
+ # @raise [Didit::WebhookVerificationError] when the delivery fails verification; +reason+ says why
47
+ # @raise [Didit::ConfigurationError] for a missing or empty secret, a negative tolerance,
48
+ # an +allow_simple+ other than true or false, or headers that cannot be read
49
+ def verify(raw_body, headers, secret, tolerance: DEFAULT_TOLERANCE, allow_simple: false, now: nil)
50
+ Verifier.new(secret, headers, tolerance: tolerance, now: now).verify(raw_body, allow_simple: allow_simple)
51
+ end
52
+
53
+ # Verifies a delivery when the framework only gives the parsed JSON body.
54
+ #
55
+ # Only +X-Signature-V2+ and +X-Signature-V2-Previous+ can be checked: the other signatures
56
+ # cover the exact body bytes. Prefer {verify} with the raw body whenever you have it.
57
+ #
58
+ # @param payload [Hash] the parsed JSON object, String or Symbol keys
59
+ # @return [Event]
60
+ # @raise (see verify)
61
+ def verify_parsed(payload, headers, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
62
+ Verifier.new(secret, headers, tolerance: tolerance, now: now).verify_parsed(payload)
63
+ end
64
+
65
+ # The canonical JSON of a parsed body, the text +X-Signature-V2+ signs: exactly Python's
66
+ # +json.dumps(shorten_floats(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False)+.
67
+ #
68
+ # @param value [Hash, Array, String, Integer, Float, true, false, nil] parsed JSON
69
+ # @return [String] UTF-8
70
+ # @raise [ArgumentError] when +value+ is not JSON data (NaN, Infinity, a lone surrogate, ...)
71
+ def canonical_json(value)
72
+ CanonicalJson.generate(value)
73
+ end
74
+
75
+ # Builds the typed event of a payload WITHOUT verifying anything.
76
+ #
77
+ # For unsigned callbacks only, such as +kyb.registry_search.resolved+: anyone can send
78
+ # such a request, so treat every field as untrusted and fetch the record from the API
79
+ # before acting on it. {Event#verified_by} is nil and {Event#body_authenticated?} false.
80
+ #
81
+ # @param payload [Hash] the parsed JSON object
82
+ # @return [Event]
83
+ # @raise [ArgumentError] when +payload+ is not a Hash
84
+ def parse_event(payload)
85
+ unless payload.is_a?(Hash)
86
+ raise ArgumentError, "parse_event needs the parsed JSON object (a Hash), not a #{payload.class}"
87
+ end
88
+
89
+ Event.build(payload)
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ require_relative "webhooks/canonical_json"
96
+ require_relative "webhooks/headers"
97
+ require_relative "webhooks/verifier"
98
+ require_relative "webhooks/events"
99
+ require_relative "webhooks/client_methods"
data/lib/didit-sdk.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lets Bundler.require load the gem by its name, didit-sdk.
4
+ require_relative "didit"
data/lib/didit.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "didit/version"
4
+ require_relative "didit/errors"
5
+
6
+ # The official Didit Ruby library.
7
+ #
8
+ # didit = Didit::Client.new # reads DIDIT_API_KEY
9
+ # session = didit.sessions.create(workflow_id: "...", vendor_data: "user-42")
10
+ #
11
+ # See https://docs.didit.me and docs/DESIGN.md.
12
+ module Didit
13
+ # The value of a keyword argument the caller did not pass. Such arguments are
14
+ # left out of the request; +nil+ sends JSON +null+.
15
+ OMIT = Object.new.tap do |omit|
16
+ def omit.inspect = "Didit::OMIT"
17
+ def omit.to_s = "Didit::OMIT"
18
+ end.freeze
19
+
20
+ autoload :Authentication, "didit/authentication"
21
+ autoload :Client, "didit/client"
22
+ autoload :FilePart, "didit/file_part"
23
+ autoload :Internal, "didit/internal"
24
+ autoload :Model, "didit/model"
25
+ autoload :Page, "didit/page"
26
+ autoload :RawResponse, "didit/raw_response"
27
+ autoload :Resource, "didit/resource"
28
+ autoload :Resources, "didit/resources"
29
+ autoload :Types, "didit/types"
30
+ autoload :Webhooks, "didit/webhooks"
31
+ end
@@ -0,0 +1,10 @@
1
+ module Didit
2
+ class Authentication
3
+ module Base64Url
4
+ ALPHABET: Regexp
5
+
6
+ def self?.encode: (String bytes) -> String
7
+ def self?.decode: (String text) -> String?
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,60 @@
1
+ module Didit
2
+ class Authentication
3
+ class Discovery
4
+ attr_reader issuer: String
5
+ attr_reader token_endpoint: String
6
+ attr_reader jwks_uri: String
7
+ attr_reader document: Hash[String, untyped]
8
+
9
+ def initialize: (issuer: String, token_endpoint: String, jwks_uri: String, document: Hash[String, untyped]) -> void
10
+ end
11
+
12
+ class IssuerCache
13
+ DEFAULT_MAX_AGE: Integer
14
+ MIN_MAX_AGE: Integer
15
+ MAX_MAX_AGE: Integer
16
+ REFETCH_INTERVAL: Integer
17
+ NO_CACHE: Array[String]
18
+
19
+ class Entry[T]
20
+ attr_reader value: T
21
+ attr_reader expires_at: Float
22
+
23
+ def initialize: (T value, Float expires_at) -> void
24
+ end
25
+
26
+ def self.cache_seconds: (Hash[String, String] headers) -> Integer
27
+ def self.deep_freeze: (untyped value) -> untyped
28
+
29
+ private
30
+
31
+ def self.max_age: (String cache_control) -> Integer?
32
+
33
+ public
34
+
35
+ @clock: ^() -> (Integer | Float)
36
+ @discovery: Entry[Discovery]?
37
+ @key_set: Entry[KeySet]?
38
+ @key_set_attempted_at: Float?
39
+ @key_set_failed: bool
40
+ @refused: Entry[String]?
41
+
42
+ def initialize: (^() -> (Integer | Float) clock) -> void
43
+ def discovery: () -> Discovery?
44
+ def store_discovery: (Discovery discovery, Hash[String, String] headers) -> Discovery
45
+ def discovery_refused: (String message) -> nil
46
+ def refused_discovery: () -> String?
47
+ def key_set: () -> KeySet?
48
+ def store_key_set: (KeySet key_set, Hash[String, String] headers) -> KeySet
49
+ def key_set_fetch_failed: () -> nil
50
+ def key_set_unavailable?: () -> bool
51
+ def key_set_with: (String kid) -> KeySet?
52
+ def needs_key_set_fetch?: (String? kid) -> bool
53
+
54
+ private
55
+
56
+ def fresh: [T] (Entry[T]? entry) -> T?
57
+ def now: () -> Float
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ module Didit
2
+ class Authentication
3
+ class Http
4
+ class Call
5
+ attr_reader http_method: String
6
+ attr_reader uri: URI::Generic
7
+ attr_reader bearer: String?
8
+ attr_reader form: Hash[String, String]?
9
+ attr_reader json: Hash[String, untyped]?
10
+ attr_reader secrets: Array[String]
11
+
12
+ def initialize: (String http_method, URI::Generic uri, ?bearer: String?, ?form: Hash[String, String]?,
13
+ ?json: Hash[String, untyped]?, ?secrets: Array[String]) -> void
14
+ def display_url: () -> String
15
+ def describe: () -> String
16
+ end
17
+
18
+ VERBS: Hash[String, Symbol]
19
+
20
+ @timeout: Float
21
+ @transport: Internal::Transport
22
+
23
+ def initialize: (timeout: Float, http_client: Internal::http_client?) -> void
24
+ def perform: (Call call) -> Internal::Transport::Response
25
+ def close: () -> nil
26
+ def read: (Call call, Internal::Transport::Response response) -> untyped
27
+ def read_object: (Call call, Internal::Transport::Response response) -> Hash[String, untyped]
28
+ def validation_error: (Call call, Internal::Transport::Response response, String message) -> APIResponseValidationError
29
+
30
+ private
31
+
32
+ def headers_for: (Call call) -> Hash[String, String]
33
+ def body_for: (Call call) -> String?
34
+ def text_of: (String body) -> String
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module Didit
2
+ class Authentication
3
+ module Issuer
4
+ LOOPBACK_HOSTS: Array[String]
5
+ PREFIXES: Hash[String, bool]
6
+ UNSAFE: Regexp
7
+
8
+ def self?.normalize: (untyped issuer, ?allow_loopback_http: bool) -> String
9
+ def self?.from_publishable_key: (untyped publishable_key) -> String
10
+ def self?.loopback?: (String host) -> bool
11
+ def self?.decode_issuer: (String prefix, String encoded) -> String
12
+ def self?.unpad: (String text) -> String
13
+ def self?.parse: (String text) -> URI::HTTP
14
+ def self?.invalid: (String text) -> bot
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ module Didit
2
+ class Authentication
3
+ module Jws
4
+ type kind = :access | :id
5
+
6
+ ALGORITHM: String
7
+ MAX_TOKEN_LENGTH: Integer
8
+ ACCESS_TYPES: Array[String]
9
+ ID_TYPES: Array[String]
10
+ ZERO: String
11
+ JSON_OPTIONS: Hash[Symbol, untyped]
12
+ DECODE_OPTIONS: Hash[Symbol, untyped]
13
+
14
+ class Parsed
15
+ attr_reader token: String
16
+ attr_reader header: Hash[String, untyped]
17
+ attr_reader claims: Hash[String, untyped]
18
+ attr_reader signature: String
19
+ attr_reader kid: String
20
+
21
+ def initialize: (token: String, header: Hash[String, untyped], claims: Hash[String, untyped], signature: String,
22
+ kid: String) -> void
23
+ end
24
+
25
+ def self?.parse: (untyped token, Symbol kind) -> Parsed
26
+ def self?.verify_signature: (Parsed parsed, OpenSSL::PKey::EC key) -> nil
27
+ def self?.check_claims: (Hash[String, untyped] claims, kind: Symbol, issuer: String, audience: Array[String]?,
28
+ nonce: String?, leeway: Float, now: Float) -> nil
29
+ def self?.shown: (untyped value) -> String
30
+ def self?.fail!: (Symbol reason, String message) -> bot
31
+ def self?.json_object: (String segment, String part) -> Hash[String, untyped]
32
+ def self?.check_header: (Hash[String, untyped] header, Symbol kind) -> void
33
+ def self?.check_type: (untyped typ, Symbol kind) -> void
34
+ def self?.check_issuer: (Hash[String, untyped] claims, String issuer) -> void
35
+ def self?.check_times: (Hash[String, untyped] claims, Float leeway, Float now) -> void
36
+ def self?.numeric_date: (Hash[String, untyped] claims, String name) -> Float
37
+ def self?.check_audience: (Hash[String, untyped] claims, Array[String] audience) -> void
38
+ def self?.check_nonce: (Hash[String, untyped] claims, String? nonce) -> void
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # The parts of the jwt gem (>= 2.7, < 4) that Didit::Authentication uses: the gem
2
+ # ships no signatures. Only Didit::Authentication::Jws calls it.
3
+ module JWT
4
+ def self.decode: (String jwt, ?untyped key, ?bool verify, ?Hash[Symbol, untyped] options) -> Array[untyped]
5
+
6
+ class DecodeError < StandardError
7
+ end
8
+
9
+ class VerificationError < DecodeError
10
+ end
11
+
12
+ class IncorrectAlgorithm < DecodeError
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ module Didit
2
+ class Authentication
3
+ class KeySet
4
+ SPKI_PREFIX: String
5
+ UNCOMPRESSED: String
6
+
7
+ def self.parse: (untyped data) -> KeySet?
8
+ def self.p256_key: (Hash[String, untyped] jwk) -> OpenSSL::PKey::EC?
9
+
10
+ private
11
+
12
+ def self.coordinate: (untyped value) -> String?
13
+
14
+ public
15
+
16
+ @keys: Hash[String, OpenSSL::PKey::EC]
17
+
18
+ attr_reader document: Hash[String, untyped]
19
+
20
+ def initialize: (Hash[String, OpenSSL::PKey::EC] keys, Hash[String, untyped] document) -> void
21
+ def []: (String kid) -> OpenSSL::PKey::EC?
22
+ def key?: (String kid) -> bool
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Didit
2
+ class Authentication
3
+ class Pkce
4
+ METHOD: String
5
+ VERIFIER: Regexp
6
+
7
+ attr_reader code_verifier: String
8
+ attr_reader code_challenge: String
9
+ attr_reader code_challenge_method: String
10
+
11
+ def self.generate: () -> Pkce
12
+ def self.s256_challenge: (String verifier) -> String
13
+
14
+ def initialize: (untyped code_verifier) -> void
15
+ def to_h: () -> { code_verifier: String, code_challenge: String, code_challenge_method: String }
16
+ def inspect: () -> String
17
+ def to_s: () -> String
18
+ end
19
+ end
20
+ end