simplicity_client 0.1.2 → 0.2.1
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/lib/fido2_client.rb +91 -0
- data/lib/simplicity_client.rb +147 -33
- metadata +26 -39
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3283578b786c0a520eeaee6b46290f1459e9075925f8fdf172534ac533270e33
|
4
|
+
data.tar.gz: cee5f3f5ac077c4cea0bc246d7f1a4b2ea03a23db7ad21795485730685ba3959
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2ddd8ee90c05b00ba4ed232846f5c2e546e087c93c7e2c3a58965c74480640fe04823bea6d94c9b3b0f58e64f41cd738b8b157420b71d5864846cb74e0600185
|
7
|
+
data.tar.gz: fbf5d020b63bae7fd804154b34513d95e9991afa39023877905072e69df66879b4afd3e7028251204e314720c4ca472860dc9e81d86d4a90e521202791dd7bbe
|
data/lib/fido2_client.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
require "json"
|
2
|
+
require "digest"
|
3
|
+
require "openssl"
|
4
|
+
require "base64"
|
5
|
+
|
6
|
+
module Fido2Client
|
7
|
+
Passkey = Data.define(:credentialId, :keyAlgorithm, :keyCurve, :keyValue, :userHandle)
|
8
|
+
Assertion = Data.define(:authenticator_data, :client_data_json, :credential_id, :user_handle, :signature)
|
9
|
+
|
10
|
+
class Client
|
11
|
+
def initialize
|
12
|
+
@origin = "https://app.simplicity.kiwi"
|
13
|
+
@rp_id = "simplicity.kiwi"
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_assertion(passkey, challenge)
|
17
|
+
collected_client_data = {
|
18
|
+
type: "webauthn.get",
|
19
|
+
challenge: challenge,
|
20
|
+
origin: @origin,
|
21
|
+
crossOrigin: false,
|
22
|
+
}
|
23
|
+
client_data_json = JSON.dump(collected_client_data)
|
24
|
+
client_data_hash = Digest::SHA256.digest(client_data_json)
|
25
|
+
|
26
|
+
# Assertion
|
27
|
+
auth_data = generate_auth_data
|
28
|
+
private_key = parse_private_key(passkey.keyAlgorithm, passkey.keyCurve, passkey.keyValue)
|
29
|
+
signature = generate_signature(auth_data, client_data_hash, private_key)
|
30
|
+
|
31
|
+
Assertion.new(
|
32
|
+
authenticator_data: Base64.urlsafe_encode64(auth_data.pack("c*"), padding: false),
|
33
|
+
client_data_json: Base64.urlsafe_encode64(client_data_json, padding: false),
|
34
|
+
credential_id: Base64.urlsafe_encode64(guid_to_raw_format(passkey.credentialId), padding: false),
|
35
|
+
user_handle: passkey.userHandle,
|
36
|
+
signature: Base64.urlsafe_encode64(signature, padding: false),
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def box(tag, lines)
|
43
|
+
lines.unshift "-----BEGIN #{tag}-----"
|
44
|
+
lines.push "-----END #{tag}-----"
|
45
|
+
lines.join("\n")
|
46
|
+
end
|
47
|
+
|
48
|
+
def der_to_pem(tag, der)
|
49
|
+
box tag, Base64.strict_encode64(der).scan(/.{1,64}/)
|
50
|
+
end
|
51
|
+
|
52
|
+
def parse_private_key(key_algorithm, key_curve, key_value)
|
53
|
+
raise "Unsupported key algorithm: #{key_algorithm}" unless key_algorithm == "ECDSA"
|
54
|
+
raise "Unsupported key curve: #{key_curve}" unless key_curve == "P-256"
|
55
|
+
|
56
|
+
# Decode the Base64 key value
|
57
|
+
key_value_bin = Base64.urlsafe_decode64(key_value)
|
58
|
+
|
59
|
+
pem = der_to_pem("PRIVATE KEY", key_value_bin)
|
60
|
+
OpenSSL::PKey::EC.new(pem)
|
61
|
+
end
|
62
|
+
|
63
|
+
def generate_signature(auth_data, client_data_hash, private_key)
|
64
|
+
sig_base = [*auth_data, *client_data_hash.bytes]
|
65
|
+
digest = OpenSSL::Digest.new("SHA256")
|
66
|
+
private_key.sign(digest, sig_base.pack("c*"))
|
67
|
+
end
|
68
|
+
|
69
|
+
def generate_auth_data
|
70
|
+
auth_data = []
|
71
|
+
rp_id_hash = Digest::SHA256.digest(@rp_id)
|
72
|
+
auth_data += rp_id_hash.bytes
|
73
|
+
|
74
|
+
# Flags asserted: Backup eligibility, Backup State, User Verification, User Presence
|
75
|
+
flags = 0x1D
|
76
|
+
auth_data.push(flags)
|
77
|
+
|
78
|
+
# Counter
|
79
|
+
auth_data += [0, 0, 0, 0]
|
80
|
+
|
81
|
+
auth_data
|
82
|
+
end
|
83
|
+
|
84
|
+
def guid_to_raw_format(guid)
|
85
|
+
raise TypeError, "GUID parameter is invalid" unless guid.match?(/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/)
|
86
|
+
|
87
|
+
# Remove the hyphens and pack the string into raw binary
|
88
|
+
[guid.delete("-")].pack("H*")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/simplicity_client.rb
CHANGED
@@ -9,29 +9,45 @@ require "faraday"
|
|
9
9
|
require "faraday-cookie_jar"
|
10
10
|
require "faraday/follow_redirects"
|
11
11
|
require "aws-sdk-cognitoidentityprovider"
|
12
|
-
require "aws-cognito-srp"
|
13
12
|
require "aws-sdk-cognitoidentity"
|
14
13
|
require "aws-sigv4"
|
14
|
+
require_relative "fido2_client"
|
15
15
|
|
16
16
|
module SimplicityClient
|
17
|
+
Params = Struct.new(:email, :credentialId, :keyAlgorithm, :keyCurve, :keyValue, :userHandle, keyword_init: true)
|
18
|
+
ParamDefinition = Struct.new(:name, :label, :primary_id)
|
19
|
+
|
17
20
|
class Error < StandardError; end
|
18
21
|
|
22
|
+
# For the user interface
|
23
|
+
PARAMS_INFO = [
|
24
|
+
ParamDefinition.new(name: "email", label: "Email address", primary_id: true),
|
25
|
+
ParamDefinition.new(name: "credentialId", label: "Passkey credential ID"),
|
26
|
+
ParamDefinition.new(name: "keyAlgorithm", label: "Passkey key algorithm"),
|
27
|
+
ParamDefinition.new(name: "keyCurve", label: "Passkey key curve"),
|
28
|
+
ParamDefinition.new(name: "keyValue", label: "Passkey value"),
|
29
|
+
ParamDefinition.new(name: "userHandle", label: "Passkey user handle")
|
30
|
+
].freeze
|
31
|
+
|
19
32
|
class Session
|
20
33
|
def initialize
|
21
34
|
@logger = Logger.new $stderr
|
22
|
-
@logger.level = Logger::
|
35
|
+
@logger.level = Logger::DEBUG
|
23
36
|
|
24
37
|
@user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"
|
25
38
|
|
26
39
|
@client = Faraday.new(
|
27
40
|
headers: { "User-Agent" => @user_agent },
|
28
|
-
|
29
|
-
|
30
|
-
|
41
|
+
# proxy: "http://Thinkbook.local:8080",
|
42
|
+
# :ssl => {:verify => false}
|
43
|
+
) do |builder|
|
31
44
|
builder.response :follow_redirects
|
32
|
-
|
45
|
+
builder.response :logger
|
33
46
|
builder.adapter Faraday.default_adapter
|
34
47
|
end
|
48
|
+
|
49
|
+
@authsignal_base = "https://au.api.authsignal.com/v1"
|
50
|
+
@authsignal_tenant_id = "e768822f-b1a1-404c-a685-0e37905ca5f5"
|
35
51
|
end
|
36
52
|
|
37
53
|
# Write the session data out to a string so that the session can be restored later
|
@@ -47,49 +63,76 @@ module SimplicityClient
|
|
47
63
|
@auth_data = json["auth_data"]
|
48
64
|
end
|
49
65
|
|
50
|
-
def login(
|
51
|
-
client = Aws::CognitoIdentityProvider::Client.new(region:
|
66
|
+
def login(params)
|
67
|
+
client = Aws::CognitoIdentityProvider::Client.new(region: "ap-southeast-2")
|
52
68
|
|
53
69
|
user_pool_id = "ap-southeast-2_abAklW6ap"
|
54
70
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
71
|
+
device_id = SecureRandom.uuid
|
72
|
+
passkey = Fido2Client::Passkey.new(params.credentialId, params.keyAlgorithm, params.keyCurve, params.keyValue, params.userHandle)
|
73
|
+
challenge_id = fetch_challenge_id
|
74
|
+
authentication_options = fetch_authentication_options(challenge_id)
|
75
|
+
challenge = authentication_options["options"]["challenge"]
|
76
|
+
assertion = Fido2Client::Client.new.get_assertion(passkey, challenge)
|
77
|
+
passkey_result = present_passkey(challenge_id, assertion, device_id)
|
78
|
+
|
79
|
+
resp = client.initiate_auth(
|
80
|
+
{
|
81
|
+
auth_flow: "CUSTOM_AUTH",
|
82
|
+
auth_parameters: {
|
83
|
+
USERNAME: params.email,
|
84
|
+
},
|
85
|
+
client_metadata: {
|
86
|
+
anonymousId: device_id,
|
87
|
+
},
|
88
|
+
client_id: "kvoiu7unft0c8hqqsa6hkmeu5",
|
89
|
+
},
|
61
90
|
)
|
62
91
|
|
63
|
-
resp =
|
92
|
+
resp = client.respond_to_auth_challenge(
|
93
|
+
{
|
94
|
+
challenge_name: "CUSTOM_CHALLENGE",
|
95
|
+
challenge_responses: {
|
96
|
+
USERNAME: params.email,
|
97
|
+
ANSWER: passkey_result["accessToken"],
|
98
|
+
},
|
99
|
+
client_id: "kvoiu7unft0c8hqqsa6hkmeu5",
|
100
|
+
session: resp.session,
|
101
|
+
},
|
102
|
+
)
|
64
103
|
|
65
|
-
cognito_identity_client = Aws::CognitoIdentity::Client.new(region:
|
104
|
+
cognito_identity_client = Aws::CognitoIdentity::Client.new(region: "ap-southeast-2")
|
66
105
|
|
67
106
|
# Assuming you have the Identity Pool ID and the ID token
|
68
|
-
identity_pool_id =
|
69
|
-
id_token = resp.id_token
|
107
|
+
identity_pool_id = "ap-southeast-2:0ed33fc6-4cef-4f2e-b634-31c616e108e2"
|
108
|
+
id_token = resp.authentication_result.id_token
|
70
109
|
|
71
110
|
# Get ID from the identity pool
|
72
|
-
id_response = cognito_identity_client.get_id(
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
111
|
+
id_response = cognito_identity_client.get_id(
|
112
|
+
{
|
113
|
+
identity_pool_id: identity_pool_id,
|
114
|
+
logins: {
|
115
|
+
"cognito-idp.ap-southeast-2.amazonaws.com/#{user_pool_id}" => id_token,
|
116
|
+
},
|
117
|
+
},
|
118
|
+
)
|
78
119
|
|
79
120
|
# Get credentials for the ID
|
80
|
-
credentials_response = cognito_identity_client.get_credentials_for_identity(
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
121
|
+
credentials_response = cognito_identity_client.get_credentials_for_identity(
|
122
|
+
{
|
123
|
+
identity_id: id_response.identity_id,
|
124
|
+
logins: {
|
125
|
+
"cognito-idp.ap-southeast-2.amazonaws.com/#{user_pool_id}" => id_token,
|
126
|
+
},
|
127
|
+
},
|
128
|
+
)
|
86
129
|
|
87
130
|
access_key_id = credentials_response.credentials.access_key_id
|
88
131
|
secret_key = credentials_response.credentials.secret_key
|
89
132
|
session_token = credentials_response.credentials.session_token
|
90
133
|
|
91
134
|
@auth_data = {
|
92
|
-
email:
|
135
|
+
email: params.email,
|
93
136
|
access_key_id: access_key_id,
|
94
137
|
secret_key: secret_key,
|
95
138
|
region: "ap-southeast-2",
|
@@ -126,12 +169,12 @@ module SimplicityClient
|
|
126
169
|
signature = signer.sign_request(
|
127
170
|
http_method: http_method,
|
128
171
|
url: url,
|
129
|
-
body: body
|
172
|
+
body: body,
|
130
173
|
)
|
131
174
|
|
132
175
|
response = @client.post(url) do |req|
|
133
176
|
req.headers = signature.headers.merge({
|
134
|
-
|
177
|
+
"Content-Type" => "application/json",
|
135
178
|
})
|
136
179
|
req.body = body
|
137
180
|
end
|
@@ -154,5 +197,76 @@ module SimplicityClient
|
|
154
197
|
raise Error, "Failed to list accounts: #{response.status}, #{response.body}"
|
155
198
|
end
|
156
199
|
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
def fetch_challenge_id
|
204
|
+
response = @client.post("#{@authsignal_base}/client/challenge") do |req|
|
205
|
+
req.headers = {
|
206
|
+
"Authorization" => "Basic #{Base64.encode64(@authsignal_tenant_id)}",
|
207
|
+
"Content-Type" => "application/json",
|
208
|
+
}
|
209
|
+
req.body = {
|
210
|
+
action: "cognitoAuth",
|
211
|
+
}.to_json
|
212
|
+
end
|
213
|
+
|
214
|
+
if response.success?
|
215
|
+
obj = JSON.parse(response.body)
|
216
|
+
obj["challengeId"]
|
217
|
+
else
|
218
|
+
raise Error, "Failed to get challenge ID: #{response.status}, #{response.body}"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def fetch_authentication_options(challenge_id)
|
223
|
+
response = @client.post("#{@authsignal_base}/client/user-authenticators/passkey/authentication-options") do |req|
|
224
|
+
req.headers = {
|
225
|
+
"Authorization" => "Basic #{Base64.encode64(@authsignal_tenant_id)}",
|
226
|
+
"Content-Type" => "application/json",
|
227
|
+
}
|
228
|
+
req.body = {
|
229
|
+
challengeId: challenge_id,
|
230
|
+
}.to_json
|
231
|
+
end
|
232
|
+
|
233
|
+
if response.success?
|
234
|
+
JSON.parse(response.body)
|
235
|
+
else
|
236
|
+
raise Error, "Failed to get authentication options: #{response.status}, #{response.body}"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def present_passkey(challenge_id, assertion, device_id)
|
241
|
+
response = @client.post("#{@authsignal_base}/client/verify/passkey") do |req|
|
242
|
+
req.headers = {
|
243
|
+
"Authorization" => "Basic #{Base64.encode64(@authsignal_tenant_id)}",
|
244
|
+
"Content-Type" => "application/json",
|
245
|
+
}
|
246
|
+
req.body = {
|
247
|
+
challengeId: challenge_id,
|
248
|
+
authenticationCredential: {
|
249
|
+
id: assertion.credential_id,
|
250
|
+
rawId: assertion.credential_id,
|
251
|
+
response: {
|
252
|
+
authenticatorData: assertion.authenticator_data,
|
253
|
+
clientDataJSON: assertion.client_data_json,
|
254
|
+
signature: assertion.signature,
|
255
|
+
userHandle: assertion.user_handle,
|
256
|
+
},
|
257
|
+
type: "public-key",
|
258
|
+
clientExtensionResults: {},
|
259
|
+
authenticatorAttachment: "platform",
|
260
|
+
},
|
261
|
+
deviceId: device_id,
|
262
|
+
}.to_json
|
263
|
+
end
|
264
|
+
|
265
|
+
if response.success?
|
266
|
+
JSON.parse(response.body)
|
267
|
+
else
|
268
|
+
raise Error, "Failed to get JWT: #{response.status}, #{response.body}"
|
269
|
+
end
|
270
|
+
end
|
157
271
|
end
|
158
272
|
end
|
metadata
CHANGED
@@ -1,113 +1,99 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: simplicity_client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- George Dewar
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-01-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
16
|
-
requirements:
|
17
|
-
- - "~>"
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: '2.7'
|
20
|
-
type: :runtime
|
21
|
-
prerelease: false
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
23
|
-
requirements:
|
24
|
-
- - "~>"
|
25
|
-
- !ruby/object:Gem::Version
|
26
|
-
version: '2.7'
|
27
|
-
- !ruby/object:Gem::Dependency
|
28
|
-
name: faraday-cookie_jar
|
14
|
+
name: aws-sdk-cognitoidentity
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
30
16
|
requirements:
|
31
17
|
- - "~>"
|
32
18
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
19
|
+
version: 1.52.0
|
34
20
|
type: :runtime
|
35
21
|
prerelease: false
|
36
22
|
version_requirements: !ruby/object:Gem::Requirement
|
37
23
|
requirements:
|
38
24
|
- - "~>"
|
39
25
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
26
|
+
version: 1.52.0
|
41
27
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
28
|
+
name: aws-sdk-cognitoidentityprovider
|
43
29
|
requirement: !ruby/object:Gem::Requirement
|
44
30
|
requirements:
|
45
31
|
- - "~>"
|
46
32
|
- !ruby/object:Gem::Version
|
47
|
-
version:
|
33
|
+
version: '1.87'
|
48
34
|
type: :runtime
|
49
35
|
prerelease: false
|
50
36
|
version_requirements: !ruby/object:Gem::Requirement
|
51
37
|
requirements:
|
52
38
|
- - "~>"
|
53
39
|
- !ruby/object:Gem::Version
|
54
|
-
version:
|
40
|
+
version: '1.87'
|
55
41
|
- !ruby/object:Gem::Dependency
|
56
|
-
name: aws-
|
42
|
+
name: aws-sigv4
|
57
43
|
requirement: !ruby/object:Gem::Requirement
|
58
44
|
requirements:
|
59
45
|
- - "~>"
|
60
46
|
- !ruby/object:Gem::Version
|
61
|
-
version:
|
47
|
+
version: 1.8.0
|
62
48
|
type: :runtime
|
63
49
|
prerelease: false
|
64
50
|
version_requirements: !ruby/object:Gem::Requirement
|
65
51
|
requirements:
|
66
52
|
- - "~>"
|
67
53
|
- !ruby/object:Gem::Version
|
68
|
-
version:
|
54
|
+
version: 1.8.0
|
69
55
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
56
|
+
name: faraday
|
71
57
|
requirement: !ruby/object:Gem::Requirement
|
72
58
|
requirements:
|
73
59
|
- - "~>"
|
74
60
|
- !ruby/object:Gem::Version
|
75
|
-
version:
|
61
|
+
version: '2.7'
|
76
62
|
type: :runtime
|
77
63
|
prerelease: false
|
78
64
|
version_requirements: !ruby/object:Gem::Requirement
|
79
65
|
requirements:
|
80
66
|
- - "~>"
|
81
67
|
- !ruby/object:Gem::Version
|
82
|
-
version:
|
68
|
+
version: '2.7'
|
83
69
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
70
|
+
name: faraday-cookie_jar
|
85
71
|
requirement: !ruby/object:Gem::Requirement
|
86
72
|
requirements:
|
87
73
|
- - "~>"
|
88
74
|
- !ruby/object:Gem::Version
|
89
|
-
version:
|
75
|
+
version: 0.0.7
|
90
76
|
type: :runtime
|
91
77
|
prerelease: false
|
92
78
|
version_requirements: !ruby/object:Gem::Requirement
|
93
79
|
requirements:
|
94
80
|
- - "~>"
|
95
81
|
- !ruby/object:Gem::Version
|
96
|
-
version:
|
82
|
+
version: 0.0.7
|
97
83
|
- !ruby/object:Gem::Dependency
|
98
|
-
name:
|
84
|
+
name: faraday-follow_redirects
|
99
85
|
requirement: !ruby/object:Gem::Requirement
|
100
86
|
requirements:
|
101
87
|
- - "~>"
|
102
88
|
- !ruby/object:Gem::Version
|
103
|
-
version:
|
89
|
+
version: 0.3.0
|
104
90
|
type: :runtime
|
105
91
|
prerelease: false
|
106
92
|
version_requirements: !ruby/object:Gem::Requirement
|
107
93
|
requirements:
|
108
94
|
- - "~>"
|
109
95
|
- !ruby/object:Gem::Version
|
110
|
-
version:
|
96
|
+
version: 0.3.0
|
111
97
|
description: This gem can log into Simplicity and fetch fund balances
|
112
98
|
email:
|
113
99
|
- george@dewar.co.nz
|
@@ -115,12 +101,13 @@ executables: []
|
|
115
101
|
extensions: []
|
116
102
|
extra_rdoc_files: []
|
117
103
|
files:
|
104
|
+
- lib/fido2_client.rb
|
118
105
|
- lib/simplicity_client.rb
|
119
|
-
homepage: https://
|
106
|
+
homepage: https://github.com/GeorgeDewar/simplicity_client
|
120
107
|
licenses:
|
121
108
|
- MIT
|
122
109
|
metadata: {}
|
123
|
-
post_install_message:
|
110
|
+
post_install_message:
|
124
111
|
rdoc_options: []
|
125
112
|
require_paths:
|
126
113
|
- lib
|
@@ -135,8 +122,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
135
122
|
- !ruby/object:Gem::Version
|
136
123
|
version: '0'
|
137
124
|
requirements: []
|
138
|
-
rubygems_version: 3.
|
139
|
-
signing_key:
|
125
|
+
rubygems_version: 3.5.16
|
126
|
+
signing_key:
|
140
127
|
specification_version: 4
|
141
128
|
summary: Fetch KiwiSaver and investment fund balances from Simplicity
|
142
129
|
test_files: []
|