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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +12 -2
  4. data/examples/webhook-server/Gemfile +1 -0
  5. data/examples/webhook-server/Gemfile.lock +28 -3
  6. data/examples/webhook-server/main.rb +15 -14
  7. data/lib/truelayer-signing/jwt.rb +1 -1
  8. data/lib/truelayer-signing/signer.rb +3 -0
  9. data/lib/truelayer-signing/utils.rb +8 -1
  10. data/lib/truelayer-signing/verifier.rb +80 -20
  11. data/lib/truelayer-signing.rb +9 -1
  12. data/test/resources/failed-payment-expired-test-payload.json +12 -0
  13. data/test/resources/missing-zero-padding-test-jwks.json +19 -0
  14. data/test/resources/missing-zero-padding-test-payload.json +22 -0
  15. data/test/resources/missing-zero-padding-test-signature.txt +1 -0
  16. data/test/test-truelayer-signing.rb +141 -7
  17. data/truelayer-signing.gemspec +2 -2
  18. metadata +10 -75
  19. data/doc/CHANGELOG_md.html +0 -132
  20. data/doc/JWT/Decode.html +0 -97
  21. data/doc/JWT/Encode.html +0 -97
  22. data/doc/JWT/JWK/EC.html +0 -169
  23. data/doc/JWT/JWK.html +0 -91
  24. data/doc/JWT.html +0 -95
  25. data/doc/LICENSE-APACHE.html +0 -177
  26. data/doc/LICENSE-MIT.html +0 -105
  27. data/doc/README_md.html +0 -197
  28. data/doc/Rakefile.html +0 -106
  29. data/doc/TrueLayerSigning/Config.html +0 -211
  30. data/doc/TrueLayerSigning/Error.html +0 -97
  31. data/doc/TrueLayerSigning/JwsBase.html +0 -317
  32. data/doc/TrueLayerSigning/JwsHeader.html +0 -268
  33. data/doc/TrueLayerSigning/Signer.html +0 -186
  34. data/doc/TrueLayerSigning/Verifier.html +0 -327
  35. data/doc/TrueLayerSigning.html +0 -226
  36. data/doc/TrueLayerSigningExamples.html +0 -217
  37. data/doc/created.rid +0 -21
  38. data/doc/css/fonts.css +0 -167
  39. data/doc/css/rdoc.css +0 -662
  40. data/doc/examples/sign-request/Gemfile.html +0 -99
  41. data/doc/examples/sign-request/Gemfile_lock.html +0 -143
  42. data/doc/examples/sign-request/README_md.html +0 -138
  43. data/doc/examples/webhook-server/Gemfile.html +0 -99
  44. data/doc/examples/webhook-server/Gemfile_lock.html +0 -142
  45. data/doc/examples/webhook-server/README_md.html +0 -139
  46. data/doc/fonts/Lato-Light.ttf +0 -0
  47. data/doc/fonts/Lato-LightItalic.ttf +0 -0
  48. data/doc/fonts/Lato-Regular.ttf +0 -0
  49. data/doc/fonts/Lato-RegularItalic.ttf +0 -0
  50. data/doc/fonts/SourceCodePro-Bold.ttf +0 -0
  51. data/doc/fonts/SourceCodePro-Regular.ttf +0 -0
  52. data/doc/images/add.png +0 -0
  53. data/doc/images/arrow_up.png +0 -0
  54. data/doc/images/brick.png +0 -0
  55. data/doc/images/brick_link.png +0 -0
  56. data/doc/images/bug.png +0 -0
  57. data/doc/images/bullet_black.png +0 -0
  58. data/doc/images/bullet_toggle_minus.png +0 -0
  59. data/doc/images/bullet_toggle_plus.png +0 -0
  60. data/doc/images/date.png +0 -0
  61. data/doc/images/delete.png +0 -0
  62. data/doc/images/find.png +0 -0
  63. data/doc/images/loadingAnimation.gif +0 -0
  64. data/doc/images/macFFBgHack.png +0 -0
  65. data/doc/images/package.png +0 -0
  66. data/doc/images/page_green.png +0 -0
  67. data/doc/images/page_white_text.png +0 -0
  68. data/doc/images/page_white_width.png +0 -0
  69. data/doc/images/plugin.png +0 -0
  70. data/doc/images/ruby.png +0 -0
  71. data/doc/images/tag_blue.png +0 -0
  72. data/doc/images/tag_green.png +0 -0
  73. data/doc/images/transparent.png +0 -0
  74. data/doc/images/wrench.png +0 -0
  75. data/doc/images/wrench_orange.png +0 -0
  76. data/doc/images/zoom.png +0 -0
  77. data/doc/index.html +0 -118
  78. data/doc/js/darkfish.js +0 -84
  79. data/doc/js/navigation.js +0 -105
  80. data/doc/js/navigation.js.gz +0 -0
  81. data/doc/js/search.js +0 -110
  82. data/doc/js/search_index.js +0 -1
  83. data/doc/js/search_index.js.gz +0 -0
  84. data/doc/js/searcher.js +0 -229
  85. data/doc/js/searcher.js.gz +0 -0
  86. data/doc/table_of_contents.html +0 -269
  87. data/examples/sign-request/Gemfile.lock +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cc4b5beb4b79ccfafd7350d4882927d45abea2580d4d4d869d39faae157498d
4
- data.tar.gz: ab43241ee2475b2797ff43fc5c142b778ff4e4ef28d042a123b9ac8179d913aa
3
+ metadata.gz: 751c6c37cee7adedf204491894146d577e70b310d9cd50504c01c29836743271
4
+ data.tar.gz: d2b7de41a966d0e114dd368320f35578714a674da1d76121d500944f0c33596b
5
5
  SHA512:
6
- metadata.gz: 6e208e6b3ed16190946cdeceae856ebcddc42b472458b86c527e0dfe52a67fe0d33e954c018f63fcb8502b659081aa35522516f9dacfb12f492b36a7830fe6b6
7
- data.tar.gz: 3282b084e0907c8c709dfa2f3d2bef71527bca7181c342a24de63262c70ffa3a9e41f4437eb77f667a1e9c6a45776b8db6549ce709b244128b5fb58006b4afea
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 `verify_with_pem` method may be used to verify webhook `Tl-Signature` header signatures.
62
+ The `verify_with_jwks` method may be used to verify webhook `Tl-Signature` header signatures.
63
63
 
64
64
  ```ruby
65
- TrueLayerSigning.verify_with_pem(pem)
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,4 +1,5 @@
1
1
  source "https://rubygems.org"
2
2
 
3
+ gem "http"
3
4
  gem "truelayer-signing"
4
5
  gem "webrick"
@@ -1,15 +1,40 @@
1
1
  GEM
2
2
  remote: https://rubygems.org/
3
3
  specs:
4
- jwt (2.6.0)
5
- truelayer-signing (0.1.1)
6
- jwt (= 2.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 'securerandom'
2
- require 'truelayer-signing'
3
- require 'webrick'
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)
@@ -29,7 +29,7 @@ module JWT
29
29
  ).segments
30
30
  end
31
31
 
32
- def truelayer_decode(jwt, key, verify, options, &keyfinder)
32
+ def truelayer_decode(jwt, key, verify = true, options, &keyfinder)
33
33
  TrueLayerDecode.new(
34
34
  jwt,
35
35
  key,
@@ -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(s) declared in signature") unless value
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
- attr_reader :required_headers, :key_value
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
- payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers), padding: false)
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
@@ -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 = File.read(File.expand_path("../../test-resources/ec512-private.pem",
6
- File.dirname(__FILE__))).freeze
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 = File.read(File.expand_path("../../test-resources/tl-signature.txt",
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(s) declared in signature", error.message)
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 }
@@ -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.2"
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.6")
24
+ s.add_runtime_dependency("jwt", "~> 2.7")
25
25
  end