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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d7f5d02a8a4a808faa00f6c78558b2e11c90a7c5b852062682746c1ab2a3da1
4
- data.tar.gz: c24bf4296b70c0b5ffc6af71faae40d4c559301f59654358c6c34e94130bbef3
3
+ metadata.gz: 52d72498c008ed13bec684867aef1f1b693c6f9ba0ed824e981ae554787c19a8
4
+ data.tar.gz: 47d0c4dbfc8511f80b0ac8b9d2270fbd68f969141b222c7dda61a2c8254e577c
5
5
  SHA512:
6
- metadata.gz: d4870301dfb82caf2456f2b24a7ae8e6b469375da2bf6ff86821196eaf620f3e1b734deae2a391884e7eaa6dd3eea150c6714962d1f77247e950b5e4b2bef8db
7
- data.tar.gz: 24788e31dd5f8deb079a4c3bd2dd411ab6f93e1928eb4de6ed6bf0b398d241b22c8ab8e7009be3f758c286a3131ab3d123dc16cc46ce6c0449f01eb88a4c181c
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. 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).
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
- def self.from_credentials(path_or_json)
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
- File.read(path_or_json)
36
- else
37
- path_or_json
38
- end
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(api_base_url: api_base_url, api_key: api_key, private_key_pkcs8: private_key)
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
- def initialize(api_base_url:, api_key:, private_key_pkcs8:)
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
- @base_uri = URI.parse(api_base_url.to_s.sub(%r{/+\z}, "") + "/")
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", { "uid" => uid })
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 << { "accountId" => a["id"], "uid" => uid } unless uid.nil?
150
+ items << {"accountId" => a["id"], "uid" => uid} unless uid.nil?
124
151
  end
125
152
 
126
- result = post_json("api/sync", { "items" => items })
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 = 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
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).freeze
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 = OpenSSL::PKey::EC::Point.new(GROUP, OpenSSL::BN.new(eph_pub_bytes, 2))
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenBankingIO
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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.