truelayer-signing 0.1.2 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +12 -2
- data/examples/webhook-server/Gemfile +1 -0
- data/examples/webhook-server/Gemfile.lock +28 -3
- data/examples/webhook-server/main.rb +15 -14
- data/lib/truelayer-signing/jwt.rb +1 -1
- data/lib/truelayer-signing/signer.rb +3 -0
- data/lib/truelayer-signing/utils.rb +8 -1
- data/lib/truelayer-signing/verifier.rb +80 -20
- data/lib/truelayer-signing.rb +9 -1
- data/test/resources/failed-payment-expired-test-payload.json +12 -0
- data/test/resources/missing-zero-padding-test-jwks.json +19 -0
- data/test/resources/missing-zero-padding-test-payload.json +22 -0
- data/test/resources/missing-zero-padding-test-signature.txt +1 -0
- data/test/test-truelayer-signing.rb +141 -7
- data/truelayer-signing.gemspec +2 -2
- metadata +10 -75
- data/doc/CHANGELOG_md.html +0 -132
- data/doc/JWT/Decode.html +0 -97
- data/doc/JWT/Encode.html +0 -97
- data/doc/JWT/JWK/EC.html +0 -169
- data/doc/JWT/JWK.html +0 -91
- data/doc/JWT.html +0 -95
- data/doc/LICENSE-APACHE.html +0 -177
- data/doc/LICENSE-MIT.html +0 -105
- data/doc/README_md.html +0 -197
- data/doc/Rakefile.html +0 -106
- data/doc/TrueLayerSigning/Config.html +0 -211
- data/doc/TrueLayerSigning/Error.html +0 -97
- data/doc/TrueLayerSigning/JwsBase.html +0 -317
- data/doc/TrueLayerSigning/JwsHeader.html +0 -268
- data/doc/TrueLayerSigning/Signer.html +0 -186
- data/doc/TrueLayerSigning/Verifier.html +0 -327
- data/doc/TrueLayerSigning.html +0 -226
- data/doc/TrueLayerSigningExamples.html +0 -217
- data/doc/created.rid +0 -21
- data/doc/css/fonts.css +0 -167
- data/doc/css/rdoc.css +0 -662
- data/doc/examples/sign-request/Gemfile.html +0 -99
- data/doc/examples/sign-request/Gemfile_lock.html +0 -143
- data/doc/examples/sign-request/README_md.html +0 -138
- data/doc/examples/webhook-server/Gemfile.html +0 -99
- data/doc/examples/webhook-server/Gemfile_lock.html +0 -142
- data/doc/examples/webhook-server/README_md.html +0 -139
- data/doc/fonts/Lato-Light.ttf +0 -0
- data/doc/fonts/Lato-LightItalic.ttf +0 -0
- data/doc/fonts/Lato-Regular.ttf +0 -0
- data/doc/fonts/Lato-RegularItalic.ttf +0 -0
- data/doc/fonts/SourceCodePro-Bold.ttf +0 -0
- data/doc/fonts/SourceCodePro-Regular.ttf +0 -0
- data/doc/images/add.png +0 -0
- data/doc/images/arrow_up.png +0 -0
- data/doc/images/brick.png +0 -0
- data/doc/images/brick_link.png +0 -0
- data/doc/images/bug.png +0 -0
- data/doc/images/bullet_black.png +0 -0
- data/doc/images/bullet_toggle_minus.png +0 -0
- data/doc/images/bullet_toggle_plus.png +0 -0
- data/doc/images/date.png +0 -0
- data/doc/images/delete.png +0 -0
- data/doc/images/find.png +0 -0
- data/doc/images/loadingAnimation.gif +0 -0
- data/doc/images/macFFBgHack.png +0 -0
- data/doc/images/package.png +0 -0
- data/doc/images/page_green.png +0 -0
- data/doc/images/page_white_text.png +0 -0
- data/doc/images/page_white_width.png +0 -0
- data/doc/images/plugin.png +0 -0
- data/doc/images/ruby.png +0 -0
- data/doc/images/tag_blue.png +0 -0
- data/doc/images/tag_green.png +0 -0
- data/doc/images/transparent.png +0 -0
- data/doc/images/wrench.png +0 -0
- data/doc/images/wrench_orange.png +0 -0
- data/doc/images/zoom.png +0 -0
- data/doc/index.html +0 -118
- data/doc/js/darkfish.js +0 -84
- data/doc/js/navigation.js +0 -105
- data/doc/js/navigation.js.gz +0 -0
- data/doc/js/search.js +0 -110
- data/doc/js/search_index.js +0 -1
- data/doc/js/search_index.js.gz +0 -0
- data/doc/js/searcher.js +0 -229
- data/doc/js/searcher.js.gz +0 -0
- data/doc/table_of_contents.html +0 -269
- data/examples/sign-request/Gemfile.lock +0 -40
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 751c6c37cee7adedf204491894146d577e70b310d9cd50504c01c29836743271
|
4
|
+
data.tar.gz: d2b7de41a966d0e114dd368320f35578714a674da1d76121d500944f0c33596b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 88a7bc727e82df06eccb1992036730e49f6de45a6d38d6df7d2cf8d749441a8d1f5be031e59410e45778da20d84fac03bb07f7bf11103387893baebd4d86af2b
|
7
|
+
data.tar.gz: 249a44e6a94171f89f69f1e53aff377bee6ac6de33a3f2383a6c7f4ed94b517e216cd81da450caf1f363f21e1a8f60edcb6dfc71481af0c7fc70336d028ea0de
|
data/CHANGELOG.md
CHANGED
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
9
9
|
|
10
10
|
- ...
|
11
11
|
|
12
|
+
## [0.2.1] – 2023-07-14
|
13
|
+
|
14
|
+
- Disable expiration verification
|
15
|
+
- Add missing header discovery
|
16
|
+
|
17
|
+
## [0.2.0] – 2023-06-13
|
18
|
+
|
19
|
+
- Add support for signature verification using a JWKS: `TrueLayerSigning.verify_with_jwks(jwks)`
|
20
|
+
and `TrueLayerSigning.extract_jws_header(signature)`.
|
21
|
+
|
12
22
|
## [0.1.2] – 2023-05-19
|
13
23
|
|
14
24
|
- Fix conflict with JWT library
|
data/README.md
CHANGED
@@ -59,10 +59,20 @@ See full example of [request signing].
|
|
59
59
|
|
60
60
|
## Verifying webhooks
|
61
61
|
|
62
|
-
The `
|
62
|
+
The `verify_with_jwks` method may be used to verify webhook `Tl-Signature` header signatures.
|
63
63
|
|
64
64
|
```ruby
|
65
|
-
|
65
|
+
# The `jku` field is included in webhook signatures
|
66
|
+
jku = TrueLayerSigning.extract_jws_header(webhook_signature).jku
|
67
|
+
|
68
|
+
# You should check that the `jku` is a valid TrueLayer URL (not provided by this library)
|
69
|
+
ensure_jku_allowed(jku)
|
70
|
+
|
71
|
+
# Then fetch JSON Web Key Set from the public URL (not provided by this library)
|
72
|
+
jwks = fetch_jwks(jku)
|
73
|
+
|
74
|
+
# The raw JWKS value may be used directly to verify a signature
|
75
|
+
TrueLayerSigning.verify_with_jwks(jwks)
|
66
76
|
.set_method(method)
|
67
77
|
.set_path(path)
|
68
78
|
.set_headers(headers)
|
@@ -1,15 +1,40 @@
|
|
1
1
|
GEM
|
2
2
|
remote: https://rubygems.org/
|
3
3
|
specs:
|
4
|
-
|
5
|
-
|
6
|
-
|
4
|
+
addressable (2.8.4)
|
5
|
+
public_suffix (>= 2.0.2, < 6.0)
|
6
|
+
domain_name (0.5.20190701)
|
7
|
+
unf (>= 0.0.5, < 1.0.0)
|
8
|
+
ffi (1.15.5)
|
9
|
+
ffi-compiler (1.0.1)
|
10
|
+
ffi (>= 1.0.0)
|
11
|
+
rake
|
12
|
+
http (5.1.1)
|
13
|
+
addressable (~> 2.8)
|
14
|
+
http-cookie (~> 1.0)
|
15
|
+
http-form_data (~> 2.2)
|
16
|
+
llhttp-ffi (~> 0.4.0)
|
17
|
+
http-cookie (1.0.5)
|
18
|
+
domain_name (~> 0.5)
|
19
|
+
http-form_data (2.3.0)
|
20
|
+
jwt (2.7.0)
|
21
|
+
llhttp-ffi (0.4.0)
|
22
|
+
ffi-compiler (~> 1.0)
|
23
|
+
rake (~> 13.0)
|
24
|
+
public_suffix (5.0.1)
|
25
|
+
rake (13.0.6)
|
26
|
+
truelayer-signing (0.2.0)
|
27
|
+
jwt (~> 2.7)
|
28
|
+
unf (0.1.4)
|
29
|
+
unf_ext
|
30
|
+
unf_ext (0.0.8.2)
|
7
31
|
webrick (1.8.1)
|
8
32
|
|
9
33
|
PLATFORMS
|
10
34
|
arm64-darwin-21
|
11
35
|
|
12
36
|
DEPENDENCIES
|
37
|
+
http
|
13
38
|
truelayer-signing
|
14
39
|
webrick
|
15
40
|
|
@@ -1,23 +1,14 @@
|
|
1
|
-
require
|
2
|
-
require
|
3
|
-
require
|
1
|
+
require "http"
|
2
|
+
require "truelayer-signing"
|
3
|
+
require "webrick"
|
4
4
|
|
5
5
|
class TrueLayerSigningExamples
|
6
6
|
# Note: the webhook path can be whatever is configured for your application.
|
7
7
|
# Here a unique path is used, matching the example signature in the README.
|
8
8
|
WEBHOOK_PATH = "/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b".freeze
|
9
|
-
PUBLIC_KEY_PEM = <<~TXT.freeze
|
10
|
-
-----BEGIN PUBLIC KEY-----
|
11
|
-
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBJ6ET9XeVCyMy+yOetZaNNCXPhwr5
|
12
|
-
BlyDDg1CLmyNM5SvqOs8RveL6dYl4lpPur4xrPQl04ggYlVd9wnHkZnp3jcBlXw8
|
13
|
-
Lc5phyYF1q2/QV/5wp2WHIhKDqUiXC0TvlE8d7MdTAN9yolcwrh6aWZ3kesTMZif
|
14
|
-
BgItyT6PXUab8mMdI8k=
|
15
|
-
-----END PUBLIC KEY-----
|
16
|
-
TXT
|
17
9
|
|
18
10
|
class << self
|
19
11
|
def run_webhook_server
|
20
|
-
TrueLayerSigning.certificate_id ||= SecureRandom.uuid
|
21
12
|
server = WEBrick::HTTPServer.new(Port: 4567)
|
22
13
|
|
23
14
|
puts "Server running at http://localhost:4567"
|
@@ -61,9 +52,19 @@ class TrueLayerSigningExamples
|
|
61
52
|
|
62
53
|
return ["400", "Bad Request – Header `Tl-Signature` missing"] unless tl_signature
|
63
54
|
|
55
|
+
jku = TrueLayerSigning.extract_jws_header(tl_signature).jku
|
56
|
+
|
57
|
+
return ["400", "Bad Request – Signature missing `jku`"] unless jku
|
58
|
+
return ["401", "Unauthorized – Unpermitted `jku`"] \
|
59
|
+
unless jku == "https://webhooks.truelayer.com/.well-known/jwks" ||
|
60
|
+
jku == "https://webhooks.truelayer-sandbox.com/.well-known/jwks"
|
61
|
+
|
62
|
+
jwks = HTTP.get(jku)
|
63
|
+
|
64
|
+
return ["401", "Unauthorized – Unavailable `jwks` resource"] unless jwks.status.success?
|
65
|
+
|
64
66
|
begin
|
65
|
-
TrueLayerSigning
|
66
|
-
.verify_with_pem(PUBLIC_KEY_PEM)
|
67
|
+
TrueLayerSigning.verify_with_jwks(jwks.to_s)
|
67
68
|
.set_method(:post)
|
68
69
|
.set_path(path)
|
69
70
|
.set_headers(headers)
|
@@ -7,7 +7,9 @@ module TrueLayerSigning
|
|
7
7
|
|
8
8
|
private_key = OpenSSL::PKey.read(TrueLayerSigning.private_key)
|
9
9
|
jws_header_args = { tl_headers: headers }
|
10
|
+
|
10
11
|
jws_header_args[:jku] = jws_jku if jws_jku
|
12
|
+
|
11
13
|
jws_header = TrueLayerSigning::JwsHeader.new(jws_header_args).to_h
|
12
14
|
jwt = JWT.truelayer_encode(build_signing_payload, private_key, TrueLayerSigning.algorithm,
|
13
15
|
jws_header)
|
@@ -18,6 +20,7 @@ module TrueLayerSigning
|
|
18
20
|
|
19
21
|
def set_jku(jku)
|
20
22
|
@jws_jku = jku
|
23
|
+
|
21
24
|
self
|
22
25
|
end
|
23
26
|
|
@@ -16,18 +16,20 @@ module TrueLayerSigning
|
|
16
16
|
|
17
17
|
def to_h
|
18
18
|
hash = instance_variables.map { |var| [var[1..-1].to_sym, instance_variable_get(var)] }.to_h
|
19
|
+
|
19
20
|
hash.reject { |key, _value| hash[key].nil? }
|
20
21
|
end
|
21
22
|
|
22
23
|
def filter_headers(headers)
|
23
24
|
required_header_keys = tl_headers.split(",").reject { |key| key.empty? }
|
24
25
|
normalised_headers = {}
|
26
|
+
|
25
27
|
headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
|
26
28
|
|
27
29
|
ordered_headers = required_header_keys.map do |key|
|
28
30
|
value = normalised_headers[key.downcase]
|
29
31
|
|
30
|
-
raise(Error, "Missing header
|
32
|
+
raise(Error, "Missing header declared in signature: #{key.downcase}") unless value
|
31
33
|
|
32
34
|
[key, value]
|
33
35
|
end
|
@@ -50,6 +52,7 @@ module TrueLayerSigning
|
|
50
52
|
|
51
53
|
def set_method(method)
|
52
54
|
@method = method.to_s.upcase
|
55
|
+
|
53
56
|
self
|
54
57
|
end
|
55
58
|
|
@@ -57,21 +60,25 @@ module TrueLayerSigning
|
|
57
60
|
raise(Error, "Path must start with '/'") unless path.start_with?("/")
|
58
61
|
|
59
62
|
@path = path
|
63
|
+
|
60
64
|
self
|
61
65
|
end
|
62
66
|
|
63
67
|
def add_header(name, value)
|
64
68
|
@headers[name.to_s] = value
|
69
|
+
|
65
70
|
self
|
66
71
|
end
|
67
72
|
|
68
73
|
def set_headers(headers)
|
69
74
|
headers.each { |name, value| @headers[name.to_s] = value }
|
75
|
+
|
70
76
|
self
|
71
77
|
end
|
72
78
|
|
73
79
|
def set_body(body)
|
74
80
|
@body = body
|
81
|
+
|
75
82
|
self
|
76
83
|
end
|
77
84
|
|
@@ -1,9 +1,13 @@
|
|
1
1
|
module TrueLayerSigning
|
2
2
|
class Verifier < JwsBase
|
3
|
-
|
3
|
+
EXPECTED_EC_KEY_COORDS_LENGTH = 66.freeze
|
4
|
+
|
5
|
+
attr_reader :required_headers, :key_type, :key_value
|
4
6
|
|
5
7
|
def initialize(args)
|
6
8
|
super
|
9
|
+
|
10
|
+
@key_type = args[:key_type]
|
7
11
|
@key_value = args[:key_value]
|
8
12
|
end
|
9
13
|
|
@@ -11,45 +15,30 @@ module TrueLayerSigning
|
|
11
15
|
ensure_verifier_config!
|
12
16
|
|
13
17
|
jws_header, jws_header_b64, signature_b64 = self.class.parse_tl_signature(tl_signature)
|
14
|
-
public_key = OpenSSL::PKey.read(key_value)
|
15
18
|
|
16
19
|
raise(Error, "Unexpected `alg` header value") if jws_header.alg != TrueLayerSigning.algorithm
|
17
20
|
|
18
21
|
ordered_headers = jws_header.filter_headers(headers)
|
19
22
|
normalised_headers = {}
|
23
|
+
|
20
24
|
ordered_headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
|
21
25
|
|
22
26
|
raise(Error, "Signature missing required header(s)") if required_headers &&
|
23
27
|
required_headers.any? { |key| !normalised_headers.has_key?(key.downcase) }
|
24
28
|
|
25
|
-
|
26
|
-
full_signature = [jws_header_b64, payload_b64, signature_b64].join(".")
|
27
|
-
jwt_options = { algorithm: TrueLayerSigning.algorithm }
|
28
|
-
|
29
|
-
begin
|
30
|
-
JWT.truelayer_decode(full_signature, public_key, true, jwt_options)
|
31
|
-
rescue JWT::VerificationError
|
32
|
-
@path = path.end_with?("/") && path[0...-1] || path + "/"
|
33
|
-
payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers),
|
34
|
-
padding: false)
|
35
|
-
full_signature = [jws_header_b64, payload_b64, signature_b64].join(".")
|
36
|
-
|
37
|
-
begin
|
38
|
-
JWT.truelayer_decode(full_signature, public_key, true, jwt_options)
|
39
|
-
rescue
|
40
|
-
raise(Error, "Signature verification failed")
|
41
|
-
end
|
42
|
-
end
|
29
|
+
verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
|
43
30
|
end
|
44
31
|
|
45
32
|
def require_header(name)
|
46
33
|
@required_headers ||= []
|
47
34
|
@required_headers.push(name)
|
35
|
+
|
48
36
|
self
|
49
37
|
end
|
50
38
|
|
51
39
|
def require_headers(names)
|
52
40
|
@required_headers = names
|
41
|
+
|
53
42
|
self
|
54
43
|
end
|
55
44
|
|
@@ -69,6 +58,77 @@ module TrueLayerSigning
|
|
69
58
|
[jws_header, jws_header_b64, signature_b64]
|
70
59
|
end
|
71
60
|
|
61
|
+
private def verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
|
62
|
+
full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
63
|
+
|
64
|
+
begin
|
65
|
+
verify_signature(jws_header, full_signature)
|
66
|
+
rescue JWT::VerificationError
|
67
|
+
@path = path.end_with?("/") && path[0...-1] || path + "/"
|
68
|
+
full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
69
|
+
|
70
|
+
begin
|
71
|
+
verify_signature(jws_header, full_signature)
|
72
|
+
rescue JWT::VerificationError
|
73
|
+
raise(Error, "Signature verification failed")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
|
79
|
+
payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers), padding: false)
|
80
|
+
|
81
|
+
[jws_header_b64, payload_b64, signature_b64].join(".")
|
82
|
+
end
|
83
|
+
|
84
|
+
private def verify_signature(jws_header, full_signature)
|
85
|
+
case key_type
|
86
|
+
when :pem
|
87
|
+
public_key = OpenSSL::PKey.read(key_value)
|
88
|
+
when :jwks
|
89
|
+
public_key = retrieve_public_key(:jwks, key_value, jws_header)
|
90
|
+
end
|
91
|
+
|
92
|
+
jwt_options = {
|
93
|
+
algorithm: TrueLayerSigning.algorithm,
|
94
|
+
verify_expiration: false,
|
95
|
+
verify_not_before: false
|
96
|
+
}
|
97
|
+
|
98
|
+
JWT.truelayer_decode(full_signature, public_key, jwt_options)
|
99
|
+
end
|
100
|
+
|
101
|
+
private def retrieve_public_key(key_type, key_value, jws_header)
|
102
|
+
case key_type
|
103
|
+
when :pem
|
104
|
+
OpenSSL::PKey.read(key_value)
|
105
|
+
when :jwks
|
106
|
+
jwks_hash = JSON.parse(key_value, symbolize_names: true)
|
107
|
+
jwk = jwks_hash[:keys].find { |key| key[:kid] == jws_header.kid }
|
108
|
+
|
109
|
+
raise(Error, "JWKS does not include given `kid` value") unless jwk
|
110
|
+
|
111
|
+
valid_jwk = apply_zero_padding_as_needed(jwk)
|
112
|
+
|
113
|
+
JWT::JWK::EC.import(valid_jwk).public_key
|
114
|
+
else
|
115
|
+
raise(Error, "Type of public key not recognised")
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private def apply_zero_padding_as_needed(jwk)
|
120
|
+
valid_jwk = jwk.clone
|
121
|
+
|
122
|
+
%i(x y).each do |elem|
|
123
|
+
coords = Base64.urlsafe_decode64(valid_jwk[elem])
|
124
|
+
diff = EXPECTED_EC_KEY_COORDS_LENGTH - coords.length
|
125
|
+
|
126
|
+
valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff > 0
|
127
|
+
end
|
128
|
+
|
129
|
+
valid_jwk
|
130
|
+
end
|
131
|
+
|
72
132
|
private def ensure_verifier_config!
|
73
133
|
raise(Error, "Key value missing") unless key_value
|
74
134
|
end
|
data/lib/truelayer-signing.rb
CHANGED
@@ -28,8 +28,16 @@ module TrueLayerSigning
|
|
28
28
|
Signer.new
|
29
29
|
end
|
30
30
|
|
31
|
+
def extract_jws_header(signature)
|
32
|
+
Verifier.parse_tl_signature(signature).first
|
33
|
+
end
|
34
|
+
|
35
|
+
def verify_with_jwks(jwks)
|
36
|
+
Verifier.new(key_type: :jwks, key_value: jwks)
|
37
|
+
end
|
38
|
+
|
31
39
|
def verify_with_pem(pem)
|
32
|
-
Verifier.new(key_value: pem)
|
40
|
+
Verifier.new(key_type: :pem, key_value: pem)
|
33
41
|
end
|
34
42
|
end
|
35
43
|
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
{
|
2
|
+
"type": "payment_failed",
|
3
|
+
"event_version": 1,
|
4
|
+
"event_id": "6f00897f-f8c8-4ffb-93a3-cfbd311396e2",
|
5
|
+
"payment_id": "2c4db314-b509-4d68-b883-a34ca5fa7b72",
|
6
|
+
"payment_method": {
|
7
|
+
"type": "bank_transfer"
|
8
|
+
},
|
9
|
+
"failed_at": "2023-07-11T17:42:24.123Z",
|
10
|
+
"failure_stage": "authorization_required",
|
11
|
+
"failure_reason": "expired"
|
12
|
+
}
|
@@ -0,0 +1,19 @@
|
|
1
|
+
{
|
2
|
+
"keys": [
|
3
|
+
{
|
4
|
+
"kty": "RSA",
|
5
|
+
"alg": "RS512",
|
6
|
+
"kid": "c9034c0f-bd06-4dd1-98fe-f67a5a1a0601",
|
7
|
+
"n": "yWTtfMrBLrVaMgUO2Oz83kMHbJX0aCSwkQ4gZoXruicEBfhWKSC4VV6WB3CZIBGfxGhJhSBFkFizdZzjSTQYIB-OIQcFR2FtP5tSSpK2K7d4-CvBs78_rOFyA4Vz4aoYLlJOWmFurMod27BmQe1UrDRm0SWJbE9L8TqtQYKDva0sWeQPuxj2Stv4BBf7Z5Zq8NyCAiW-TMmhV7silLT8eN7MLp6X_-BFoHgZSL26ECd5KkQxVE8kl9Cl-GelmcbT8CIM4LQ8vTSYB6ADHcAte24xtP3IUv5_K-gFaNucbC1i95X0Ha2UNx13uFfaBVOngwFyAnU7eFlZh_MJS_kCXw",
|
8
|
+
"e": "AQAB"
|
9
|
+
},
|
10
|
+
{
|
11
|
+
"kty": "EC",
|
12
|
+
"alg": "ES512",
|
13
|
+
"kid": "d3534b83-bdc7-4066-adb5-c8d4bf616601",
|
14
|
+
"crv": "P-521",
|
15
|
+
"x": "AQO9n3CGUtsvQEWivE7KTkbaqY-xxsa0EPhplLxj0Nak6dS400ug0VhMqgTXfyc3nV3UA7Mz7x5dDL13YyUtfiOq",
|
16
|
+
"y": "fRU9nhcoS3FUZD7UzBUt9AOOpShMlV8yXC3VXn0FTRB7ySDqZnr1th0ZYnt0uihMDKYKB0-TrptWg1hgA75aeiI"
|
17
|
+
}
|
18
|
+
]
|
19
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
{
|
2
|
+
"type": "payment_executed",
|
3
|
+
"event_version": 1,
|
4
|
+
"event_id": "2606ed6d-5c89-4bd9-8f95-40e42c95ca71",
|
5
|
+
"payment_id": "a62ba556-4b35-4628-9d8c-42302d2e5d02",
|
6
|
+
"payment_method": {
|
7
|
+
"type": "bank_transfer",
|
8
|
+
"provider_id": "mock-payments-gb-redirect",
|
9
|
+
"scheme_id": "faster_payments_service"
|
10
|
+
},
|
11
|
+
"executed_at": "2023-06-09T15:40:30.561Z",
|
12
|
+
"payment_source": {
|
13
|
+
"account_identifiers": [
|
14
|
+
{
|
15
|
+
"type": "sort_code_account_number",
|
16
|
+
"sort_code": "040668",
|
17
|
+
"account_number": "00000871"
|
18
|
+
}
|
19
|
+
],
|
20
|
+
"account_holder_name": "JOHN SANDBRIDGE"
|
21
|
+
}
|
22
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
eyJhbGciOiJFUzUxMiIsImtpZCI6ImQzNTM0YjgzLWJkYzctNDA2Ni1hZGI1LWM4ZDRiZjYxNjYwMSIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGVhZGVycyI6IngtdGwtd2ViaG9vay10aW1lc3RhbXAiLCJqa3UiOiJodHRwczovL3dlYmhvb2tzLnRydWVsYXllci1zYW5kYm94LmNvbS8ud2VsbC1rbm93bi9qd2tzIn0..AVKH1WQK4Z4tIF3I-gU1AkLI7o4Dk-ZpALG7rKuMPQdksVwzUVBa8zq3LdvV2SHNzlH50NhGVYi-j4nC8G23Qd5UAW8DCvMB1ynTBVE_3vKzbmbfh8Dg3TAPvZCdajQiDLrLJp5iiUtcRgML2EI_zt9SEAkpIaSxFU8DHnMm8CN3YEQT
|
@@ -1,11 +1,13 @@
|
|
1
1
|
require "minitest/autorun"
|
2
2
|
require "truelayer-signing"
|
3
3
|
|
4
|
+
def read_file(path)
|
5
|
+
File.read(File.expand_path(path, File.dirname(__FILE__)))
|
6
|
+
end
|
7
|
+
|
4
8
|
CERTIFICATE_ID = "45fc75cf-5649-4134-84b3-192c2c78e990".freeze
|
5
|
-
PRIVATE_KEY =
|
6
|
-
|
7
|
-
PUBLIC_KEY = File.read(File.expand_path("../../test-resources/ec512-public.pem",
|
8
|
-
File.dirname(__FILE__))).freeze
|
9
|
+
PRIVATE_KEY = read_file("../../test-resources/ec512-private.pem").freeze
|
10
|
+
PUBLIC_KEY = read_file("../../test-resources/ec512-public.pem").freeze
|
9
11
|
|
10
12
|
TrueLayerSigning.certificate_id = CERTIFICATE_ID.freeze
|
11
13
|
TrueLayerSigning.private_key = PRIVATE_KEY.freeze
|
@@ -59,6 +61,24 @@ class TrueLayerSigningTest < Minitest::Test
|
|
59
61
|
refute(result.first.include?("\nIdempotency-Key: "))
|
60
62
|
end
|
61
63
|
|
64
|
+
def test_full_request_signature_without_method_should_default_to_post_and_succeed
|
65
|
+
body = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
|
66
|
+
path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
|
67
|
+
|
68
|
+
tl_signature = TrueLayerSigning.sign_with_pem
|
69
|
+
.set_path(path)
|
70
|
+
.set_body(body)
|
71
|
+
.sign
|
72
|
+
|
73
|
+
result = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
74
|
+
.set_path(path)
|
75
|
+
.set_body(body)
|
76
|
+
.verify(tl_signature)
|
77
|
+
|
78
|
+
assert(result.first
|
79
|
+
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
80
|
+
end
|
81
|
+
|
62
82
|
def test_mismatched_signature_with_attached_valid_body_should_fail
|
63
83
|
# Signature for `/bar` but with a valid jws-body pre-attached.
|
64
84
|
# If we run a simple jws verify on this unchanged, it'll work!
|
@@ -101,8 +121,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
101
121
|
body = { currency: "GBP", max_amount_in_minor: 50_000_00, name: "Foo???" }.to_json
|
102
122
|
idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"
|
103
123
|
path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
|
104
|
-
tl_signature =
|
105
|
-
File.dirname(__FILE__)))
|
124
|
+
tl_signature = read_file("../../test-resources/tl-signature.txt")
|
106
125
|
|
107
126
|
result = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
108
127
|
.set_method(:post)
|
@@ -295,7 +314,7 @@ class TrueLayerSigningTest < Minitest::Test
|
|
295
314
|
.set_body(body)
|
296
315
|
|
297
316
|
error = assert_raises(TrueLayerSigning::Error) { verifier.verify(tl_signature) }
|
298
|
-
assert_equal("Missing header
|
317
|
+
assert_equal("Missing header declared in signature: idempotency-key", error.message)
|
299
318
|
end
|
300
319
|
|
301
320
|
def test_full_request_signature_missing_required_header_should_fail
|
@@ -370,6 +389,121 @@ class TrueLayerSigningTest < Minitest::Test
|
|
370
389
|
.start_with?("POST /merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping\n"))
|
371
390
|
end
|
372
391
|
|
392
|
+
def test_extract_jws_header_should_succeed
|
393
|
+
hook_signature = read_file("../../test-resources/webhook-signature.txt")
|
394
|
+
jws_header = TrueLayerSigning.extract_jws_header(hook_signature)
|
395
|
+
|
396
|
+
assert_equal("ES512", jws_header.alg)
|
397
|
+
assert_equal(CERTIFICATE_ID, jws_header.kid)
|
398
|
+
assert_equal("2", jws_header.tl_version)
|
399
|
+
assert_equal("X-Tl-Webhook-Timestamp,Content-Type", jws_header.tl_headers)
|
400
|
+
assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header.jku)
|
401
|
+
end
|
402
|
+
|
403
|
+
def test_verify_with_jwks_should_succeed
|
404
|
+
hook_signature = read_file("../../test-resources/webhook-signature.txt")
|
405
|
+
jwks = read_file("../../test-resources/jwks.json")
|
406
|
+
body = { event_type: "example", event_id: "18b2842b-a57b-4887-a0a6-d3c7c36f1020" }.to_json
|
407
|
+
|
408
|
+
TrueLayerSigning.verify_with_jwks(jwks)
|
409
|
+
.set_method(:post)
|
410
|
+
.set_path("/tl-webhook")
|
411
|
+
.add_header("x-tl-webhook-timestamp", "2021-11-29T11:42:55Z")
|
412
|
+
.add_header("content-type", "application/json")
|
413
|
+
.set_body(body)
|
414
|
+
.verify(hook_signature)
|
415
|
+
end
|
416
|
+
|
417
|
+
def test_verify_with_jwks_with_zero_padding_missing_should_succeed
|
418
|
+
jwks = read_file("resources/missing-zero-padding-test-jwks.json")
|
419
|
+
|
420
|
+
# JWKS with EC key missing zero padded coords is not supported by `jwt` gem
|
421
|
+
|
422
|
+
jwks_as_json = JSON.parse(jwks, symbolize_names: true)
|
423
|
+
jwk_missing_padding = jwks_as_json[:keys].find { |e| e[:kty] == "EC" }
|
424
|
+
imported_jwk = JWT::JWK::EC.import(jwk_missing_padding)
|
425
|
+
|
426
|
+
error = assert_raises(OpenSSL::PKey::EC::Point::Error) { imported_jwk.public_key.check_key }
|
427
|
+
assert_equal("EC_POINT_bn2point: invalid encoding", error.message)
|
428
|
+
|
429
|
+
# But supported by `truelayer-signing` using zero padding (prepend)
|
430
|
+
|
431
|
+
payload = read_file("resources/missing-zero-padding-test-payload.json")
|
432
|
+
body = JSON.parse(payload).to_json
|
433
|
+
|
434
|
+
TrueLayerSigning.verify_with_jwks(jwks)
|
435
|
+
.set_method(:post)
|
436
|
+
.set_path("/a147f26a-f07e-47e3-9526-d52f1f1fdd55")
|
437
|
+
.add_header("x-tl-webhook-timestamp", "2023-06-09T15:40:30Z")
|
438
|
+
.set_body(body)
|
439
|
+
.verify(read_file("resources/missing-zero-padding-test-signature.txt"))
|
440
|
+
end
|
441
|
+
|
442
|
+
def test_verify_with_jwks_with_wrong_timestamp_should_fail
|
443
|
+
hook_signature = read_file("../../test-resources/webhook-signature.txt")
|
444
|
+
jwks = read_file("../../test-resources/jwks.json")
|
445
|
+
body = { event_type: "example", event_id: "18b2842b-a57b-4887-a0a6-d3c7c36f1020" }.to_json
|
446
|
+
|
447
|
+
verifier = TrueLayerSigning.verify_with_jwks(jwks)
|
448
|
+
.set_method(:post)
|
449
|
+
.set_path("/tl-webhook")
|
450
|
+
.add_header("x-tl-webhook-timestamp", "2021-12-29T11:42:55Z")
|
451
|
+
.add_header("content-type", "application/json")
|
452
|
+
.set_body(body)
|
453
|
+
|
454
|
+
error = assert_raises(TrueLayerSigning::Error) { verifier.verify(hook_signature) }
|
455
|
+
assert_equal("Signature verification failed", error.message)
|
456
|
+
end
|
457
|
+
|
458
|
+
# This test reproduces an issue we had with an edge case
|
459
|
+
def test_verify_with_failed_payment_expired_webhook_should_succeed
|
460
|
+
path = "/tl-webhook"
|
461
|
+
payload = read_file("resources/failed-payment-expired-test-payload.json")
|
462
|
+
body = JSON.parse(payload).to_json
|
463
|
+
|
464
|
+
tl_signature = TrueLayerSigning.sign_with_pem
|
465
|
+
.set_method(:post)
|
466
|
+
.set_path(path)
|
467
|
+
.set_body(body)
|
468
|
+
.sign
|
469
|
+
|
470
|
+
result = TrueLayerSigning.verify_with_pem(PUBLIC_KEY)
|
471
|
+
.set_method(:post)
|
472
|
+
.set_path(path)
|
473
|
+
.set_body(body)
|
474
|
+
.verify(tl_signature)
|
475
|
+
|
476
|
+
assert(result.first.start_with?("POST /tl-webhook\n"))
|
477
|
+
assert(result.first.include?("\"failure_reason\":\"expired\""))
|
478
|
+
end
|
479
|
+
|
480
|
+
def test_sign_with_pem_and_custom_jku_should_succeed
|
481
|
+
body = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
|
482
|
+
idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"
|
483
|
+
path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
|
484
|
+
|
485
|
+
tl_signature_1 = TrueLayerSigning.sign_with_pem
|
486
|
+
.set_path(path)
|
487
|
+
.add_header("Idempotency-Key", idempotency_key)
|
488
|
+
.set_body(body)
|
489
|
+
.sign
|
490
|
+
|
491
|
+
jws_header_1 = TrueLayerSigning.extract_jws_header(tl_signature_1)
|
492
|
+
|
493
|
+
assert_nil(jws_header_1.jku)
|
494
|
+
|
495
|
+
tl_signature_2 = TrueLayerSigning.sign_with_pem
|
496
|
+
.set_path(path)
|
497
|
+
.add_header("Idempotency-Key", idempotency_key)
|
498
|
+
.set_body(body)
|
499
|
+
.set_jku("https://webhooks.truelayer.com/.well-known/jwks")
|
500
|
+
.sign
|
501
|
+
|
502
|
+
jws_header_2 = TrueLayerSigning.extract_jws_header(tl_signature_2)
|
503
|
+
|
504
|
+
assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header_2.jku)
|
505
|
+
end
|
506
|
+
|
373
507
|
# TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
|
374
508
|
def test_jwt_encode_and_decode_should_succeed
|
375
509
|
payload_object = { currency: "GBP", max_amount_in_minor: 50_000_00 }
|
data/truelayer-signing.gemspec
CHANGED
@@ -2,7 +2,7 @@ $LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib"))
|
|
2
2
|
|
3
3
|
Gem::Specification.new do |s|
|
4
4
|
s.name = "truelayer-signing"
|
5
|
-
s.version = "0.1
|
5
|
+
s.version = "0.2.1"
|
6
6
|
s.summary = "Ruby gem to produce and verify TrueLayer API requests signatures"
|
7
7
|
s.description = "TrueLayer provides instant access to open banking to " \
|
8
8
|
"easily integrate next-generation payments and financial data into any app." \
|
@@ -21,5 +21,5 @@ Gem::Specification.new do |s|
|
|
21
21
|
s.require_paths = ["lib"]
|
22
22
|
|
23
23
|
s.required_ruby_version = ">= 2.7"
|
24
|
-
s.add_runtime_dependency("jwt", "2.
|
24
|
+
s.add_runtime_dependency("jwt", "~> 2.7")
|
25
25
|
end
|