open-banking-io 0.1.0 → 0.3.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 +4 -4
- data/README.md +11 -2
- data/lib/open_banking_io/client.rb +52 -15
- data/lib/open_banking_io/envelope.rb +13 -2
- data/lib/open_banking_io/version.rb +1 -1
- metadata +57 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52d72498c008ed13bec684867aef1f1b693c6f9ba0ed824e981ae554787c19a8
|
|
4
|
+
data.tar.gz: 47d0c4dbfc8511f80b0ac8b9d2270fbd68f969141b222c7dda61a2c8254e577c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13cce72e500507d12756086fc0d3dd6595556fb4200f6d9da9c69bc112558aefafa684043814988b8e58a0ee1907a2480d9d29b0721c9f6de73677afb3b25818
|
|
7
|
+
data.tar.gz: f607fde4595bf4b1499d32db231d7939d60b064dfe2308cdcecd80557735bf7c0ad062052003d11787c5f2d661269b24518130dd41c5c5a1e52dd8963c97b6fe
|
data/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://open-banking.io">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/open-banking-io/clients/main/.github/logo.png" alt="open-banking.io" height="56">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# open-banking-io (Ruby)
|
|
2
8
|
|
|
3
9
|
Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
|
|
@@ -49,12 +55,15 @@ client = OpenBankingIO::Client.new(
|
|
|
49
55
|
|
|
50
56
|
Amounts are exposed as `BigDecimal`. Models are immutable keyword-initialised `Struct`s.
|
|
51
57
|
|
|
58
|
+
Every request sets connect/read timeouts (15s/60s) and a `User-Agent: open-banking-io/ruby/<version>` header.
|
|
59
|
+
|
|
52
60
|
## Encryption
|
|
53
61
|
|
|
54
62
|
Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**, implemented entirely with Ruby's OpenSSL
|
|
55
63
|
standard library. Decryption requires the private key from your credentials bundle and happens fully
|
|
56
|
-
in-process.
|
|
57
|
-
|
|
64
|
+
in-process. Full wire format and the other language clients:
|
|
65
|
+
[repo README](https://github.com/open-banking-io/clients) ·
|
|
66
|
+
[`THREAT_MODEL.md`](https://github.com/open-banking-io/clients/blob/main/THREAT_MODEL.md).
|
|
58
67
|
|
|
59
68
|
## Development
|
|
60
69
|
|
|
@@ -7,6 +7,7 @@ require "bigdecimal"
|
|
|
7
7
|
|
|
8
8
|
require_relative "envelope"
|
|
9
9
|
require_relative "models"
|
|
10
|
+
require_relative "version"
|
|
10
11
|
|
|
11
12
|
module OpenBankingIO
|
|
12
13
|
# Raised when the API returns a non-success HTTP status.
|
|
@@ -28,14 +29,20 @@ module OpenBankingIO
|
|
|
28
29
|
class Client
|
|
29
30
|
DEFAULT_OPEN_TIMEOUT = 15
|
|
30
31
|
DEFAULT_READ_TIMEOUT = 60
|
|
32
|
+
USER_AGENT = "open-banking-io/ruby/#{VERSION}".freeze
|
|
31
33
|
|
|
32
34
|
# Builds a client from a credentials-bundle JSON string or a path to a bundle file.
|
|
33
|
-
|
|
35
|
+
#
|
|
36
|
+
# Optional +open_timeout+/+read_timeout+ (seconds) and a caller-supplied +http_client+
|
|
37
|
+
# (an object responding to +#request+, e.g. a preconfigured Net::HTTP) are threaded
|
|
38
|
+
# straight through to +#initialize+; the defaults preserve the previous behaviour.
|
|
39
|
+
def self.from_credentials(path_or_json, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
40
|
+
read_timeout: DEFAULT_READ_TIMEOUT, http_client: nil)
|
|
34
41
|
raw = if File.file?(path_or_json.to_s)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
File.read(path_or_json)
|
|
43
|
+
else
|
|
44
|
+
path_or_json
|
|
45
|
+
end
|
|
39
46
|
|
|
40
47
|
bundle = JSON.parse(raw)
|
|
41
48
|
api_base_url = bundle["apiBaseUrl"].to_s
|
|
@@ -48,17 +55,37 @@ module OpenBankingIO
|
|
|
48
55
|
raise ArgumentError, "The credentials bundle has no encryption private key"
|
|
49
56
|
end
|
|
50
57
|
|
|
51
|
-
new(
|
|
58
|
+
new(
|
|
59
|
+
api_base_url: api_base_url,
|
|
60
|
+
api_key: api_key,
|
|
61
|
+
private_key_pkcs8: private_key,
|
|
62
|
+
open_timeout: open_timeout,
|
|
63
|
+
read_timeout: read_timeout,
|
|
64
|
+
http_client: http_client
|
|
65
|
+
)
|
|
52
66
|
end
|
|
53
67
|
|
|
54
|
-
|
|
68
|
+
# +open_timeout+/+read_timeout+ are seconds applied to the internally built Net::HTTP.
|
|
69
|
+
# +http_client+, when given, is any object responding to +#request+ (e.g. a preconfigured
|
|
70
|
+
# Net::HTTP for a proxy, custom CA/mTLS or connection pooling); it is used as-is instead
|
|
71
|
+
# of building one internally. All three are optional and default to the previous behaviour.
|
|
72
|
+
def initialize(api_base_url:, api_key:, private_key_pkcs8:,
|
|
73
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
|
|
74
|
+
http_client: nil)
|
|
55
75
|
raise ArgumentError, "api_base_url is required" if blank?(api_base_url)
|
|
56
76
|
raise ArgumentError, "api_key is required" if blank?(api_key)
|
|
57
77
|
raise ArgumentError, "private_key_pkcs8 is required" if blank?(private_key_pkcs8)
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
# Strip any trailing slashes without a backtracking regex (avoids ReDoS on
|
|
80
|
+
# pathological input) then re-append a single one, so the base always ends in "/".
|
|
81
|
+
base = api_base_url.to_s
|
|
82
|
+
base = base.chomp("/") while base.end_with?("/")
|
|
83
|
+
@base_uri = URI.parse(base + "/")
|
|
60
84
|
@api_key = api_key
|
|
61
85
|
@private_key = Envelope.load_private_key(private_key_pkcs8)
|
|
86
|
+
@open_timeout = open_timeout
|
|
87
|
+
@read_timeout = read_timeout
|
|
88
|
+
@http_client = http_client
|
|
62
89
|
end
|
|
63
90
|
|
|
64
91
|
# Lists the user's accounts with all sensitive fields decrypted.
|
|
@@ -108,7 +135,7 @@ module OpenBankingIO
|
|
|
108
135
|
raise ArgumentError, "Account has no active session (reconnect required) -- cannot sync"
|
|
109
136
|
end
|
|
110
137
|
|
|
111
|
-
result = post_json("api/accounts/#{account_id}/sync", {
|
|
138
|
+
result = post_json("api/accounts/#{account_id}/sync", {"uid" => uid})
|
|
112
139
|
SyncResult.new(
|
|
113
140
|
new_transactions: result["newTransactions"] || 0,
|
|
114
141
|
total_fetched: result["totalFetched"] || 0
|
|
@@ -120,10 +147,10 @@ module OpenBankingIO
|
|
|
120
147
|
items = []
|
|
121
148
|
account_wires.each do |a|
|
|
122
149
|
uid = decrypt_uid(a)
|
|
123
|
-
items << {
|
|
150
|
+
items << {"accountId" => a["id"], "uid" => uid} unless uid.nil?
|
|
124
151
|
end
|
|
125
152
|
|
|
126
|
-
result = post_json("api/sync", {
|
|
153
|
+
result = post_json("api/sync", {"items" => items})
|
|
127
154
|
SyncAllResult.new(
|
|
128
155
|
accounts: result["accounts"] || 0,
|
|
129
156
|
new_transactions: result["newTransactions"] || 0
|
|
@@ -228,6 +255,10 @@ module OpenBankingIO
|
|
|
228
255
|
uri.query = URI.encode_www_form(params)
|
|
229
256
|
end
|
|
230
257
|
|
|
258
|
+
# `path` is an internal, library-controlled API route resolved against the configured
|
|
259
|
+
# base URI (see #resolve), not user-supplied file/URL input. This is an HTTP API client;
|
|
260
|
+
# issuing the request is its purpose.
|
|
261
|
+
# nosemgrep: ruby.rails.security.audit.avoid-tainted-http-request.avoid-tainted-http-request
|
|
231
262
|
request = Net::HTTP::Get.new(uri)
|
|
232
263
|
send_request(uri, request)
|
|
233
264
|
end
|
|
@@ -247,11 +278,9 @@ module OpenBankingIO
|
|
|
247
278
|
def send_request(uri, request)
|
|
248
279
|
request["X-Api-Key"] = @api_key
|
|
249
280
|
request["Accept"] = "application/json"
|
|
281
|
+
request["User-Agent"] = USER_AGENT
|
|
250
282
|
|
|
251
|
-
http =
|
|
252
|
-
http.use_ssl = (uri.scheme == "https")
|
|
253
|
-
http.open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
254
|
-
http.read_timeout = DEFAULT_READ_TIMEOUT
|
|
283
|
+
http = @http_client || build_http(uri)
|
|
255
284
|
|
|
256
285
|
response = http.request(request)
|
|
257
286
|
code = response.code.to_i
|
|
@@ -262,5 +291,13 @@ module OpenBankingIO
|
|
|
262
291
|
|
|
263
292
|
JSON.parse(body)
|
|
264
293
|
end
|
|
294
|
+
|
|
295
|
+
def build_http(uri)
|
|
296
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
297
|
+
http.use_ssl = (uri.scheme == "https")
|
|
298
|
+
http.open_timeout = @open_timeout
|
|
299
|
+
http.read_timeout = @read_timeout
|
|
300
|
+
http
|
|
301
|
+
end
|
|
265
302
|
end
|
|
266
303
|
end
|
|
@@ -15,7 +15,7 @@ module OpenBankingIO
|
|
|
15
15
|
POINT_LEN = 65
|
|
16
16
|
NONCE_LEN = 12
|
|
17
17
|
TAG_LEN = 16
|
|
18
|
-
HKDF_SALT = ("\x00".b * 32)
|
|
18
|
+
HKDF_SALT = ("\x00".b * 32)
|
|
19
19
|
HKDF_INFO = "bank.core.ci/zk/v1".b.freeze
|
|
20
20
|
GROUP = OpenSSL::PKey::EC::Group.new("prime256v1")
|
|
21
21
|
|
|
@@ -43,7 +43,7 @@ module OpenBankingIO
|
|
|
43
43
|
tag = envelope_bytes.byteslice(1 + POINT_LEN + NONCE_LEN, TAG_LEN)
|
|
44
44
|
ciphertext = envelope_bytes.byteslice((1 + POINT_LEN + NONCE_LEN + TAG_LEN)..) || "".b
|
|
45
45
|
|
|
46
|
-
pub =
|
|
46
|
+
pub = decode_public_point(eph_pub_bytes)
|
|
47
47
|
shared = private_key.dh_compute_key(pub)
|
|
48
48
|
|
|
49
49
|
key = OpenSSL::KDF.hkdf(
|
|
@@ -63,6 +63,17 @@ module OpenBankingIO
|
|
|
63
63
|
cipher.update(ciphertext) + cipher.final
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Parses the 65-byte raw ephemeral public key into a P-256 point.
|
|
67
|
+
#
|
|
68
|
+
# A malformed or off-curve point makes +EC::Point.new+ raise an OpenSSL-internal
|
|
69
|
+
# error; we wrap it in a clean +ArgumentError+ so callers see a consistent envelope
|
|
70
|
+
# error rather than a leaking implementation detail.
|
|
71
|
+
def decode_public_point(eph_pub_bytes)
|
|
72
|
+
OpenSSL::PKey::EC::Point.new(GROUP, OpenSSL::BN.new(eph_pub_bytes, 2))
|
|
73
|
+
rescue OpenSSL::PKey::EC::Point::Error, OpenSSL::BNError => e
|
|
74
|
+
raise ArgumentError, "Invalid ephemeral public key in envelope: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
66
77
|
# Decrypts a base64 envelope and parses its JSON payload. +nil+ in -> +nil+ out.
|
|
67
78
|
def decrypt_to_json(private_key, envelope_b64)
|
|
68
79
|
return nil if envelope_b64.nil?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: open-banking-io
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- open-banking.io
|
|
@@ -65,6 +65,62 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '3.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.22'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.22'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: simplecov-cobertura
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.1'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.1'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rexml
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.4'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.4'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: standard
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '1.0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '1.0'
|
|
68
124
|
description: Authenticates with your API key and decrypts open-banking.io's zero-knowledge
|
|
69
125
|
data envelopes locally with your exported private key (ECDH P-256 -> HKDF-SHA256
|
|
70
126
|
-> AES-256-GCM). The service only ever returns ciphertext it cannot read.
|