open-banking-io 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: 6d7f5d02a8a4a808faa00f6c78558b2e11c90a7c5b852062682746c1ab2a3da1
4
+ data.tar.gz: c24bf4296b70c0b5ffc6af71faae40d4c559301f59654358c6c34e94130bbef3
5
+ SHA512:
6
+ metadata.gz: d4870301dfb82caf2456f2b24a7ae8e6b469375da2bf6ff86821196eaf620f3e1b734deae2a391884e7eaa6dd3eea150c6714962d1f77247e950b5e4b2bef8db
7
+ data.tar.gz: 24788e31dd5f8deb079a4c3bd2dd411ab6f93e1928eb4de6ed6bf0b398d241b22c8ab8e7009be3f758c286a3131ab3d123dc16cc46ce6c0449f01eb88a4c181c
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # open-banking-io (Ruby)
2
+
3
+ Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
4
+ **API key** and decrypts the **zero-knowledge** data envelopes locally with your exported **private
5
+ key** — the service only ever returns ciphertext it cannot read.
6
+
7
+ ```bash
8
+ gem install open-banking-io
9
+ ```
10
+
11
+ ```ruby
12
+ require "open_banking_io"
13
+
14
+ # Load the credentials .json you exported from the app (API key + private key).
15
+ client = OpenBankingIO::Client.from_credentials("credentials.json")
16
+
17
+ client.get_accounts.each do |account|
18
+ booked = account.balances.find { |b| b.type == "ITBD" }
19
+ label = account.display_name || account.owner_name
20
+ puts "#{label} #{account.iban}: #{booked&.amount} #{account.currency}"
21
+
22
+ page = client.get_transactions(account.id, limit: 50)
23
+ page.items.each do |t|
24
+ puts " #{t.booking_date} #{t.creditor_name || t.debtor_name} #{t.amount} #{t.currency}"
25
+ end
26
+
27
+ # Trigger an online sync (decrypts the account uid locally and posts it):
28
+ client.sync(account.id)
29
+ end
30
+ ```
31
+
32
+ Or construct it explicitly:
33
+
34
+ ```ruby
35
+ client = OpenBankingIO::Client.new(
36
+ api_base_url: api_base_url,
37
+ api_key: api_key,
38
+ private_key_pkcs8: private_key_pkcs8
39
+ )
40
+ ```
41
+
42
+ ## API
43
+
44
+ - `get_accounts` → `Array<Account>` — decrypts each account's envelope, display name and balances.
45
+ - `get_transactions(account_id, from: nil, to: nil, limit: nil, offset: nil)` → `TransactionPage`
46
+ - `get_connections` → `Array<Connection>`
47
+ - `sync(account_id)` → `SyncResult` — decrypts the account uid locally and posts it.
48
+ - `sync_all` → `SyncAllResult` — syncs every account that has an active session.
49
+
50
+ Amounts are exposed as `BigDecimal`. Models are immutable keyword-initialised `Struct`s.
51
+
52
+ ## Encryption
53
+
54
+ Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**, implemented entirely with Ruby's OpenSSL
55
+ standard library. Decryption requires the private key from your credentials bundle and happens fully
56
+ in-process. See the [repo README](https://github.com/open-banking-io/clients) for the full scheme and
57
+ the other language clients (.NET, Node, Python, Rust, Go, Java).
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ bundle install
63
+ bundle exec rspec
64
+ ```
65
+
66
+ MIT licensed.
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "net/http"
6
+ require "bigdecimal"
7
+
8
+ require_relative "envelope"
9
+ require_relative "models"
10
+
11
+ module OpenBankingIO
12
+ # Raised when the API returns a non-success HTTP status.
13
+ class HTTPError < StandardError
14
+ attr_reader :status, :body
15
+
16
+ def initialize(status, body)
17
+ @status = status
18
+ @body = body
19
+ super("open-banking.io request failed with HTTP #{status}")
20
+ end
21
+ end
22
+
23
+ # Server-to-server client for open-banking.io.
24
+ #
25
+ # Authenticates with an API key (+X-Api-Key+) and decrypts the zero-knowledge data
26
+ # envelopes locally with the exported private key -- the service only ever returns
27
+ # ciphertext it cannot read.
28
+ class Client
29
+ DEFAULT_OPEN_TIMEOUT = 15
30
+ DEFAULT_READ_TIMEOUT = 60
31
+
32
+ # Builds a client from a credentials-bundle JSON string or a path to a bundle file.
33
+ def self.from_credentials(path_or_json)
34
+ raw = if File.file?(path_or_json.to_s)
35
+ File.read(path_or_json)
36
+ else
37
+ path_or_json
38
+ end
39
+
40
+ bundle = JSON.parse(raw)
41
+ api_base_url = bundle["apiBaseUrl"].to_s
42
+ api_key = bundle["apiKey"]
43
+ raise ArgumentError, "The credentials bundle has no apiKey" if api_key.nil? || api_key.empty?
44
+
45
+ enc_key = bundle["encryptionKey"] || {}
46
+ private_key = enc_key["privateKey"] || enc_key["privateKeyPkcs8B64"]
47
+ if private_key.nil? || private_key.to_s.empty?
48
+ raise ArgumentError, "The credentials bundle has no encryption private key"
49
+ end
50
+
51
+ new(api_base_url: api_base_url, api_key: api_key, private_key_pkcs8: private_key)
52
+ end
53
+
54
+ def initialize(api_base_url:, api_key:, private_key_pkcs8:)
55
+ raise ArgumentError, "api_base_url is required" if blank?(api_base_url)
56
+ raise ArgumentError, "api_key is required" if blank?(api_key)
57
+ raise ArgumentError, "private_key_pkcs8 is required" if blank?(private_key_pkcs8)
58
+
59
+ @base_uri = URI.parse(api_base_url.to_s.sub(%r{/+\z}, "") + "/")
60
+ @api_key = api_key
61
+ @private_key = Envelope.load_private_key(private_key_pkcs8)
62
+ end
63
+
64
+ # Lists the user's accounts with all sensitive fields decrypted.
65
+ def get_accounts
66
+ account_wires.map { |w| map_account(w) }
67
+ end
68
+
69
+ # Returns a page of an account's statement, newest first, with decrypted fields.
70
+ def get_transactions(account_id, from: nil, to: nil, limit: nil, offset: nil)
71
+ params = {}
72
+ params["from"] = from unless from.nil?
73
+ params["to"] = to unless to.nil?
74
+ params["limit"] = limit unless limit.nil?
75
+ params["offset"] = offset unless offset.nil?
76
+
77
+ page = get_json("api/accounts/#{account_id}/transactions", params)
78
+ items = (page["items"] || []).map { |t| map_transaction(t) }
79
+ TransactionPage.new(items: items, total: page["total"] || 0)
80
+ end
81
+
82
+ # Lists the user's bank connections.
83
+ def get_connections
84
+ get_json("api/connections").map do |c|
85
+ Connection.new(
86
+ session_id: c["sessionId"] || "",
87
+ aspsp_name: c["aspspName"] || "",
88
+ aspsp_country: c["aspspCountry"] || "",
89
+ valid_until: c["validUntil"],
90
+ status: c["status"] || "",
91
+ account_count: c["accountCount"] || 0,
92
+ last_synced_at: c["lastSyncedAt"],
93
+ psu_type: c["psuType"]
94
+ )
95
+ end
96
+ end
97
+
98
+ # Triggers an online sync of one account.
99
+ #
100
+ # Decrypts that account's Enable Banking uid and posts it, so the service can fetch
101
+ # fresh data without ever holding the uid in plaintext.
102
+ def sync(account_id)
103
+ account = account_wires.find { |a| a["id"] == account_id }
104
+ raise ArgumentError, "Account #{account_id} not found" if account.nil?
105
+
106
+ uid = decrypt_uid(account)
107
+ if uid.nil?
108
+ raise ArgumentError, "Account has no active session (reconnect required) -- cannot sync"
109
+ end
110
+
111
+ result = post_json("api/accounts/#{account_id}/sync", { "uid" => uid })
112
+ SyncResult.new(
113
+ new_transactions: result["newTransactions"] || 0,
114
+ total_fetched: result["totalFetched"] || 0
115
+ )
116
+ end
117
+
118
+ # Triggers an online sync of every account that has an active session.
119
+ def sync_all
120
+ items = []
121
+ account_wires.each do |a|
122
+ uid = decrypt_uid(a)
123
+ items << { "accountId" => a["id"], "uid" => uid } unless uid.nil?
124
+ end
125
+
126
+ result = post_json("api/sync", { "items" => items })
127
+ SyncAllResult.new(
128
+ accounts: result["accounts"] || 0,
129
+ new_transactions: result["newTransactions"] || 0
130
+ )
131
+ end
132
+
133
+ private
134
+
135
+ def blank?(value)
136
+ value.nil? || value.to_s.strip.empty?
137
+ end
138
+
139
+ def account_wires
140
+ get_json("api/accounts")
141
+ end
142
+
143
+ def decrypt_uid(account)
144
+ payload = Envelope.decrypt_to_json(@private_key, account["uidEnc"])
145
+ payload && payload["uid"]
146
+ end
147
+
148
+ def map_account(a)
149
+ acc = Envelope.decrypt_to_json(@private_key, a["enc"]) || {}
150
+ name = Envelope.decrypt_to_json(@private_key, a["displayNameEnc"]) || {}
151
+
152
+ balances = (a["balances"] || []).map do |b|
153
+ dec = Envelope.decrypt_to_json(@private_key, b["enc"]) || {}
154
+ Balance.new(
155
+ type: b["type"] || "",
156
+ currency: b["currency"] || "",
157
+ reference_date: b["referenceDate"],
158
+ name: dec["name"],
159
+ amount: parse_decimal(dec["amount"])
160
+ )
161
+ end
162
+
163
+ Account.new(
164
+ id: a["id"] || "",
165
+ aspsp_name: a["aspspName"] || "",
166
+ aspsp_country: a["aspspCountry"] || "",
167
+ currency: a["currency"] || "",
168
+ account_type: a["accountType"],
169
+ bic: a["bic"],
170
+ needs_reconnect: a["needsReconnect"] || false,
171
+ iban: acc["iban"],
172
+ bban: acc["bban"],
173
+ owner_name: acc["ownerName"],
174
+ account_name: acc["accountName"],
175
+ product: acc["product"],
176
+ display_name: name["displayName"],
177
+ balances: balances
178
+ )
179
+ end
180
+
181
+ def map_transaction(t)
182
+ d = Envelope.decrypt_to_json(@private_key, t["enc"]) || {}
183
+ Transaction.new(
184
+ id: t["id"] || "",
185
+ currency: t["currency"] || "",
186
+ credit_debit_indicator: t["creditDebitIndicator"] || "",
187
+ status: t["status"],
188
+ booking_date: t["bookingDate"],
189
+ value_date: t["valueDate"],
190
+ transaction_date: t["transactionDate"],
191
+ bank_transaction_code: t["bankTransactionCode"],
192
+ amount: parse_decimal(d["amount"]),
193
+ creditor_name: d["creditorName"],
194
+ creditor_iban: d["creditorIban"],
195
+ creditor_bban: d["creditorBban"],
196
+ creditor_agent_bic: d["creditorAgentBic"],
197
+ debtor_name: d["debtorName"],
198
+ debtor_iban: d["debtorIban"],
199
+ debtor_bban: d["debtorBban"],
200
+ debtor_agent_bic: d["debtorAgentBic"],
201
+ remittance_information: d["remittanceInformation"],
202
+ note: d["note"],
203
+ reference_number: d["referenceNumber"],
204
+ exchange_rate: d["exchangeRate"],
205
+ merchant_category_code: d["merchantCategoryCode"],
206
+ balance_after_transaction: parse_decimal_nullable(d["balanceAfter"]),
207
+ balance_after_currency: d["balanceAfterCurrency"]
208
+ )
209
+ end
210
+
211
+ def parse_decimal(value)
212
+ return BigDecimal(0) if value.nil? || value == ""
213
+
214
+ BigDecimal(value.to_s)
215
+ end
216
+
217
+ def parse_decimal_nullable(value)
218
+ return nil if value.nil? || value == ""
219
+
220
+ BigDecimal(value.to_s)
221
+ end
222
+
223
+ # -- HTTP ------------------------------------------------------------------
224
+
225
+ def get_json(path, params = {})
226
+ uri = resolve(path)
227
+ unless params.empty?
228
+ uri.query = URI.encode_www_form(params)
229
+ end
230
+
231
+ request = Net::HTTP::Get.new(uri)
232
+ send_request(uri, request)
233
+ end
234
+
235
+ def post_json(path, body)
236
+ uri = resolve(path)
237
+ request = Net::HTTP::Post.new(uri)
238
+ request["Content-Type"] = "application/json"
239
+ request.body = JSON.generate(body)
240
+ send_request(uri, request)
241
+ end
242
+
243
+ def resolve(path)
244
+ (@base_uri + path.sub(%r{\A/+}, "")).dup
245
+ end
246
+
247
+ def send_request(uri, request)
248
+ request["X-Api-Key"] = @api_key
249
+ request["Accept"] = "application/json"
250
+
251
+ http = Net::HTTP.new(uri.host, uri.port)
252
+ http.use_ssl = (uri.scheme == "https")
253
+ http.open_timeout = DEFAULT_OPEN_TIMEOUT
254
+ http.read_timeout = DEFAULT_READ_TIMEOUT
255
+
256
+ response = http.request(request)
257
+ code = response.code.to_i
258
+ raise HTTPError.new(code, response.body) unless code.between?(200, 299)
259
+
260
+ body = response.body
261
+ return nil if body.nil? || body.empty?
262
+
263
+ JSON.parse(body)
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module OpenBankingIO
8
+ # Decrypts open-banking.io's zero-knowledge data envelopes.
9
+ #
10
+ # Scheme: ephemeral ECDH on NIST P-256 -> HKDF-SHA256 -> AES-256-GCM.
11
+ # Wire: +version(1)=0x01 | ephemeralPublicKeyRaw(65) | nonce(12) | tag(16) | ciphertext+.
12
+ # Only the user's private key can decrypt -- the service stores ciphertext it cannot read.
13
+ module Envelope
14
+ VERSION_BYTE = 0x01
15
+ POINT_LEN = 65
16
+ NONCE_LEN = 12
17
+ TAG_LEN = 16
18
+ HKDF_SALT = ("\x00".b * 32).freeze
19
+ HKDF_INFO = "bank.core.ci/zk/v1".b.freeze
20
+ GROUP = OpenSSL::PKey::EC::Group.new("prime256v1")
21
+
22
+ module_function
23
+
24
+ # Loads a base64 PKCS#8 EC (P-256) private key.
25
+ def load_private_key(private_key_pkcs8_b64)
26
+ key = OpenSSL::PKey.read(Base64.decode64(private_key_pkcs8_b64))
27
+ unless key.is_a?(OpenSSL::PKey::EC)
28
+ raise ArgumentError, "Private key is not an EC key"
29
+ end
30
+
31
+ key
32
+ end
33
+
34
+ # Decrypts the raw bytes of a zero-knowledge envelope, returning the plaintext bytes.
35
+ def decrypt(private_key, envelope_bytes)
36
+ min_len = 1 + POINT_LEN + NONCE_LEN + TAG_LEN
37
+ if envelope_bytes.bytesize < min_len || envelope_bytes.getbyte(0) != VERSION_BYTE
38
+ raise ArgumentError, "Invalid or unsupported envelope"
39
+ end
40
+
41
+ eph_pub_bytes = envelope_bytes.byteslice(1, POINT_LEN)
42
+ nonce = envelope_bytes.byteslice(1 + POINT_LEN, NONCE_LEN)
43
+ tag = envelope_bytes.byteslice(1 + POINT_LEN + NONCE_LEN, TAG_LEN)
44
+ ciphertext = envelope_bytes.byteslice((1 + POINT_LEN + NONCE_LEN + TAG_LEN)..) || "".b
45
+
46
+ pub = OpenSSL::PKey::EC::Point.new(GROUP, OpenSSL::BN.new(eph_pub_bytes, 2))
47
+ shared = private_key.dh_compute_key(pub)
48
+
49
+ key = OpenSSL::KDF.hkdf(
50
+ shared,
51
+ salt: HKDF_SALT,
52
+ info: HKDF_INFO,
53
+ length: 32,
54
+ hash: "SHA256"
55
+ )
56
+
57
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
58
+ cipher.decrypt
59
+ cipher.key = key
60
+ cipher.iv = nonce
61
+ cipher.auth_tag = tag
62
+ cipher.auth_data = ""
63
+ cipher.update(ciphertext) + cipher.final
64
+ end
65
+
66
+ # Decrypts a base64 envelope and parses its JSON payload. +nil+ in -> +nil+ out.
67
+ def decrypt_to_json(private_key, envelope_b64)
68
+ return nil if envelope_b64.nil?
69
+
70
+ plaintext = decrypt(private_key, Base64.decode64(envelope_b64))
71
+ JSON.parse(plaintext)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenBankingIO
4
+ # Public, decrypted models for the open-banking.io client.
5
+ #
6
+ # These are immutable keyword-initialised value objects (Struct). Amounts are exposed
7
+ # as BigDecimal; dates as String (ISO-8601) as returned by the service.
8
+
9
+ # A balance snapshot. +type+ is the ISO 20022 code (ITBD booked, ITAV available, ...).
10
+ Balance = Struct.new(
11
+ :type,
12
+ :name,
13
+ :amount, # BigDecimal
14
+ :currency,
15
+ :reference_date,
16
+ keyword_init: true
17
+ )
18
+
19
+ # A bank account with its sensitive fields decrypted.
20
+ Account = Struct.new(
21
+ :id,
22
+ :aspsp_name,
23
+ :aspsp_country,
24
+ :currency,
25
+ :account_type,
26
+ :bic,
27
+ :needs_reconnect,
28
+ :iban,
29
+ :bban,
30
+ :owner_name,
31
+ :account_name,
32
+ :product,
33
+ :display_name,
34
+ :balances, # Array<Balance>
35
+ keyword_init: true
36
+ )
37
+
38
+ # A statement transaction with its sensitive fields decrypted.
39
+ Transaction = Struct.new(
40
+ :id,
41
+ :currency,
42
+ :credit_debit_indicator,
43
+ :status,
44
+ :booking_date,
45
+ :value_date,
46
+ :transaction_date,
47
+ :bank_transaction_code,
48
+ :amount, # BigDecimal
49
+ :creditor_name,
50
+ :creditor_iban,
51
+ :creditor_bban,
52
+ :creditor_agent_bic,
53
+ :debtor_name,
54
+ :debtor_iban,
55
+ :debtor_bban,
56
+ :debtor_agent_bic,
57
+ :remittance_information,
58
+ :note,
59
+ :reference_number,
60
+ :exchange_rate,
61
+ :merchant_category_code,
62
+ :balance_after_transaction, # BigDecimal or nil
63
+ :balance_after_currency,
64
+ keyword_init: true
65
+ )
66
+
67
+ # A page of transactions, newest first.
68
+ TransactionPage = Struct.new(:items, :total, keyword_init: true)
69
+
70
+ # A bank connection (consent).
71
+ Connection = Struct.new(
72
+ :session_id,
73
+ :aspsp_name,
74
+ :aspsp_country,
75
+ :valid_until,
76
+ :status,
77
+ :account_count,
78
+ :last_synced_at,
79
+ :psu_type,
80
+ keyword_init: true
81
+ )
82
+
83
+ SyncResult = Struct.new(:new_transactions, :total_fetched, keyword_init: true)
84
+
85
+ SyncAllResult = Struct.new(:accounts, :new_transactions, keyword_init: true)
86
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenBankingIO
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "open_banking_io/version"
4
+ require_relative "open_banking_io/envelope"
5
+ require_relative "open_banking_io/models"
6
+ require_relative "open_banking_io/client"
7
+
8
+ # Server-to-server client for open-banking.io with local zero-knowledge envelope decryption.
9
+ module OpenBankingIO
10
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: open-banking-io
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - open-banking.io
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bigdecimal
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: Authenticates with your API key and decrypts open-banking.io's zero-knowledge
69
+ data envelopes locally with your exported private key (ECDH P-256 -> HKDF-SHA256
70
+ -> AES-256-GCM). The service only ever returns ciphertext it cannot read.
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - README.md
76
+ - lib/open_banking_io.rb
77
+ - lib/open_banking_io/client.rb
78
+ - lib/open_banking_io/envelope.rb
79
+ - lib/open_banking_io/models.rb
80
+ - lib/open_banking_io/version.rb
81
+ homepage: https://open-banking.io
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ homepage_uri: https://open-banking.io
86
+ source_code_uri: https://github.com/open-banking-io/clients
87
+ rubygems_mfa_required: 'true'
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.1'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.6.9
103
+ specification_version: 4
104
+ summary: Server-to-server client for open-banking.io with local zero-knowledge envelope
105
+ decryption.
106
+ test_files: []