truelayer-signing 0.1.2 → 0.2.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -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/verifier.rb +72 -20
  8. data/lib/truelayer-signing.rb +9 -1
  9. data/test/resources/missing-zero-padding-test-jwks.json +19 -0
  10. data/test/resources/missing-zero-padding-test-payload.json +22 -0
  11. data/test/resources/missing-zero-padding-test-signature.txt +1 -0
  12. data/test/test-truelayer-signing.rb +118 -6
  13. data/truelayer-signing.gemspec +2 -2
  14. metadata +9 -75
  15. data/doc/CHANGELOG_md.html +0 -132
  16. data/doc/JWT/Decode.html +0 -97
  17. data/doc/JWT/Encode.html +0 -97
  18. data/doc/JWT/JWK/EC.html +0 -169
  19. data/doc/JWT/JWK.html +0 -91
  20. data/doc/JWT.html +0 -95
  21. data/doc/LICENSE-APACHE.html +0 -177
  22. data/doc/LICENSE-MIT.html +0 -105
  23. data/doc/README_md.html +0 -197
  24. data/doc/Rakefile.html +0 -106
  25. data/doc/TrueLayerSigning/Config.html +0 -211
  26. data/doc/TrueLayerSigning/Error.html +0 -97
  27. data/doc/TrueLayerSigning/JwsBase.html +0 -317
  28. data/doc/TrueLayerSigning/JwsHeader.html +0 -268
  29. data/doc/TrueLayerSigning/Signer.html +0 -186
  30. data/doc/TrueLayerSigning/Verifier.html +0 -327
  31. data/doc/TrueLayerSigning.html +0 -226
  32. data/doc/TrueLayerSigningExamples.html +0 -217
  33. data/doc/created.rid +0 -21
  34. data/doc/css/fonts.css +0 -167
  35. data/doc/css/rdoc.css +0 -662
  36. data/doc/examples/sign-request/Gemfile.html +0 -99
  37. data/doc/examples/sign-request/Gemfile_lock.html +0 -143
  38. data/doc/examples/sign-request/README_md.html +0 -138
  39. data/doc/examples/webhook-server/Gemfile.html +0 -99
  40. data/doc/examples/webhook-server/Gemfile_lock.html +0 -142
  41. data/doc/examples/webhook-server/README_md.html +0 -139
  42. data/doc/fonts/Lato-Light.ttf +0 -0
  43. data/doc/fonts/Lato-LightItalic.ttf +0 -0
  44. data/doc/fonts/Lato-Regular.ttf +0 -0
  45. data/doc/fonts/Lato-RegularItalic.ttf +0 -0
  46. data/doc/fonts/SourceCodePro-Bold.ttf +0 -0
  47. data/doc/fonts/SourceCodePro-Regular.ttf +0 -0
  48. data/doc/images/add.png +0 -0
  49. data/doc/images/arrow_up.png +0 -0
  50. data/doc/images/brick.png +0 -0
  51. data/doc/images/brick_link.png +0 -0
  52. data/doc/images/bug.png +0 -0
  53. data/doc/images/bullet_black.png +0 -0
  54. data/doc/images/bullet_toggle_minus.png +0 -0
  55. data/doc/images/bullet_toggle_plus.png +0 -0
  56. data/doc/images/date.png +0 -0
  57. data/doc/images/delete.png +0 -0
  58. data/doc/images/find.png +0 -0
  59. data/doc/images/loadingAnimation.gif +0 -0
  60. data/doc/images/macFFBgHack.png +0 -0
  61. data/doc/images/package.png +0 -0
  62. data/doc/images/page_green.png +0 -0
  63. data/doc/images/page_white_text.png +0 -0
  64. data/doc/images/page_white_width.png +0 -0
  65. data/doc/images/plugin.png +0 -0
  66. data/doc/images/ruby.png +0 -0
  67. data/doc/images/tag_blue.png +0 -0
  68. data/doc/images/tag_green.png +0 -0
  69. data/doc/images/transparent.png +0 -0
  70. data/doc/images/wrench.png +0 -0
  71. data/doc/images/wrench_orange.png +0 -0
  72. data/doc/images/zoom.png +0 -0
  73. data/doc/index.html +0 -118
  74. data/doc/js/darkfish.js +0 -84
  75. data/doc/js/navigation.js +0 -105
  76. data/doc/js/navigation.js.gz +0 -0
  77. data/doc/js/search.js +0 -110
  78. data/doc/js/search_index.js +0 -1
  79. data/doc/js/search_index.js.gz +0 -0
  80. data/doc/js/searcher.js +0 -229
  81. data/doc/js/searcher.js.gz +0 -0
  82. data/doc/table_of_contents.html +0 -269
  83. 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: 7675d871fb8de41624e5f92fb6b0252fac90900f2f52c6bd9b182ea0f80cde44
4
+ data.tar.gz: 2345bf7cfc1619ef8c5e1bb20ed56f966ec9a71263798bde713aca0ba92b0811
5
5
  SHA512:
6
- metadata.gz: 6e208e6b3ed16190946cdeceae856ebcddc42b472458b86c527e0dfe52a67fe0d33e954c018f63fcb8502b659081aa35522516f9dacfb12f492b36a7830fe6b6
7
- data.tar.gz: 3282b084e0907c8c709dfa2f3d2bef71527bca7181c342a24de63262c70ffa3a9e41f4437eb77f667a1e9c6a45776b8db6549ce709b244128b5fb58006b4afea
6
+ metadata.gz: df411a9c02a97ab165f5a47063ec0ca3fae67fe437f96af20b1f71eab4f88105bd8259a3c9685056c91210c3dca0134721f5c3b94e9cc39691756f6b64c17279
7
+ data.tar.gz: a39b865f5c4a1a3226cc8071f3c5971ff8c32c536d4b7b88ddab8cf7ffcc71fa2e36c3d3409b5fa7f3d25647f8ea8fb202ef6f74ac51661e85c2b089f6a4e2c4
data/CHANGELOG.md CHANGED
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  - ...
11
11
 
12
+ ## [0.2.0] – 2023-06-13
13
+
14
+ - Add support for signature verification using a JWKS: `TrueLayerSigning.verify_with_jwks(jwks)`
15
+ and `TrueLayerSigning.extract_jws_header(signature)`.
16
+
12
17
  ## [0.1.2] – 2023-05-19
13
18
 
14
19
  - 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)
@@ -1,9 +1,12 @@
1
1
  module TrueLayerSigning
2
2
  class Verifier < JwsBase
3
- attr_reader :required_headers, :key_value
3
+ EXPECTED_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
+ @key_type = args[:key_type]
7
10
  @key_value = args[:key_value]
8
11
  end
9
12
 
@@ -11,7 +14,6 @@ module TrueLayerSigning
11
14
  ensure_verifier_config!
12
15
 
13
16
  jws_header, jws_header_b64, signature_b64 = self.class.parse_tl_signature(tl_signature)
14
- public_key = OpenSSL::PKey.read(key_value)
15
17
 
16
18
  raise(Error, "Unexpected `alg` header value") if jws_header.alg != TrueLayerSigning.algorithm
17
19
 
@@ -22,24 +24,7 @@ module TrueLayerSigning
22
24
  raise(Error, "Signature missing required header(s)") if required_headers &&
23
25
  required_headers.any? { |key| !normalised_headers.has_key?(key.downcase) }
24
26
 
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
27
+ verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
43
28
  end
44
29
 
45
30
  def require_header(name)
@@ -69,6 +54,73 @@ module TrueLayerSigning
69
54
  [jws_header, jws_header_b64, signature_b64]
70
55
  end
71
56
 
57
+ private def verify_signature_flex(ordered_headers, jws_header, jws_header_b64, signature_b64)
58
+ full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
59
+
60
+ begin
61
+ verify_signature(jws_header, full_signature)
62
+ rescue JWT::VerificationError
63
+ @path = path.end_with?("/") && path[0...-1] || path + "/"
64
+ full_signature = build_full_signature(ordered_headers, jws_header_b64, signature_b64)
65
+
66
+ begin
67
+ verify_signature(jws_header, full_signature)
68
+ rescue JWT::VerificationError
69
+ raise(Error, "Signature verification failed")
70
+ end
71
+ end
72
+ end
73
+
74
+ private def build_full_signature(ordered_headers, jws_header_b64, signature_b64)
75
+ payload_b64 = Base64.urlsafe_encode64(build_signing_payload(ordered_headers), padding: false)
76
+
77
+ [jws_header_b64, payload_b64, signature_b64].join(".")
78
+ end
79
+
80
+ private def verify_signature(jws_header, full_signature)
81
+ case key_type
82
+ when :pem
83
+ public_key = OpenSSL::PKey.read(key_value)
84
+ when :jwks
85
+ public_key = retrieve_public_key(:jwks, key_value, jws_header)
86
+ end
87
+
88
+ jwt_options = { algorithm: TrueLayerSigning.algorithm }
89
+ JWT.truelayer_decode(full_signature, public_key, true, jwt_options)
90
+ end
91
+
92
+ private def retrieve_public_key(key_type, key_value, jws_header)
93
+ case key_type
94
+ when :pem
95
+ OpenSSL::PKey.read(key_value)
96
+ when :jwks
97
+ jwks_hash = JSON.parse(key_value, symbolize_names: true)
98
+ jwk = jwks_hash[:keys].find { |key| key[:kid] == jws_header.kid }
99
+
100
+ raise(Error, "JWKS does not include given `kid` value") unless jwk
101
+
102
+ valid_jwk = apply_zero_padding_as_needed(jwk)
103
+
104
+ JWT::JWK::EC.import(valid_jwk).public_key
105
+ else
106
+ raise(Error, "Type of public key not recognised")
107
+ end
108
+ end
109
+
110
+ private def apply_zero_padding_as_needed(jwk)
111
+ valid_jwk = jwk.clone
112
+
113
+ %i(x y).each do |elem|
114
+ coords = Base64.urlsafe_decode64(valid_jwk[elem])
115
+ length = coords.length
116
+ diff = EXPECTED_COORDS_LENGTH - length
117
+
118
+ valid_jwk[elem] = Base64.urlsafe_encode64(("\x00" * diff) + coords) if diff > 0
119
+ end
120
+
121
+ valid_jwk
122
+ end
123
+
72
124
  private def ensure_verifier_config!
73
125
  raise(Error, "Key value missing") unless key_value
74
126
  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,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)
@@ -370,6 +389,99 @@ 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
+ def test_sign_with_pem_and_custom_jku_should_succeed
459
+ body = { currency: "GBP", max_amount_in_minor: 50_000_00 }.to_json
460
+ idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"
461
+ path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"
462
+
463
+ tl_signature_1 = TrueLayerSigning.sign_with_pem
464
+ .set_path(path)
465
+ .add_header("Idempotency-Key", idempotency_key)
466
+ .set_body(body)
467
+ .sign
468
+
469
+ jws_header_1 = TrueLayerSigning.extract_jws_header(tl_signature_1)
470
+
471
+ assert_nil(jws_header_1.jku)
472
+
473
+ tl_signature_2 = TrueLayerSigning.sign_with_pem
474
+ .set_path(path)
475
+ .add_header("Idempotency-Key", idempotency_key)
476
+ .set_body(body)
477
+ .set_jku("https://webhooks.truelayer.com/.well-known/jwks")
478
+ .sign
479
+
480
+ jws_header_2 = TrueLayerSigning.extract_jws_header(tl_signature_2)
481
+
482
+ assert_equal("https://webhooks.truelayer.com/.well-known/jwks", jws_header_2.jku)
483
+ end
484
+
373
485
  # TODO: remove if/when we get rid of `lib/truelayer-signing/jwt.rb`
374
486
  def test_jwt_encode_and_decode_should_succeed
375
487
  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.0"
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
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: truelayer-signing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Plattret
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-19 00:00:00.000000000 Z
11
+ date: 2023-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.6'
19
+ version: '2.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - '='
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.6'
26
+ version: '2.7'
27
27
  description: TrueLayer provides instant access to open banking to easily integrate
28
28
  next-generation payments and financial data into any app.This helps easily sign
29
29
  TrueLayer API requests using a JSON web signature.
@@ -37,76 +37,7 @@ files:
37
37
  - "./LICENSE-MIT"
38
38
  - "./README.md"
39
39
  - "./Rakefile"
40
- - "./doc/CHANGELOG_md.html"
41
- - "./doc/JWT.html"
42
- - "./doc/JWT/Decode.html"
43
- - "./doc/JWT/Encode.html"
44
- - "./doc/JWT/JWK.html"
45
- - "./doc/JWT/JWK/EC.html"
46
- - "./doc/LICENSE-APACHE.html"
47
- - "./doc/LICENSE-MIT.html"
48
- - "./doc/README_md.html"
49
- - "./doc/Rakefile.html"
50
- - "./doc/TrueLayerSigning.html"
51
- - "./doc/TrueLayerSigning/Config.html"
52
- - "./doc/TrueLayerSigning/Error.html"
53
- - "./doc/TrueLayerSigning/JwsBase.html"
54
- - "./doc/TrueLayerSigning/JwsHeader.html"
55
- - "./doc/TrueLayerSigning/Signer.html"
56
- - "./doc/TrueLayerSigning/Verifier.html"
57
- - "./doc/TrueLayerSigningExamples.html"
58
- - "./doc/created.rid"
59
- - "./doc/css/fonts.css"
60
- - "./doc/css/rdoc.css"
61
- - "./doc/examples/sign-request/Gemfile.html"
62
- - "./doc/examples/sign-request/Gemfile_lock.html"
63
- - "./doc/examples/sign-request/README_md.html"
64
- - "./doc/examples/webhook-server/Gemfile.html"
65
- - "./doc/examples/webhook-server/Gemfile_lock.html"
66
- - "./doc/examples/webhook-server/README_md.html"
67
- - "./doc/fonts/Lato-Light.ttf"
68
- - "./doc/fonts/Lato-LightItalic.ttf"
69
- - "./doc/fonts/Lato-Regular.ttf"
70
- - "./doc/fonts/Lato-RegularItalic.ttf"
71
- - "./doc/fonts/SourceCodePro-Bold.ttf"
72
- - "./doc/fonts/SourceCodePro-Regular.ttf"
73
- - "./doc/images/add.png"
74
- - "./doc/images/arrow_up.png"
75
- - "./doc/images/brick.png"
76
- - "./doc/images/brick_link.png"
77
- - "./doc/images/bug.png"
78
- - "./doc/images/bullet_black.png"
79
- - "./doc/images/bullet_toggle_minus.png"
80
- - "./doc/images/bullet_toggle_plus.png"
81
- - "./doc/images/date.png"
82
- - "./doc/images/delete.png"
83
- - "./doc/images/find.png"
84
- - "./doc/images/loadingAnimation.gif"
85
- - "./doc/images/macFFBgHack.png"
86
- - "./doc/images/package.png"
87
- - "./doc/images/page_green.png"
88
- - "./doc/images/page_white_text.png"
89
- - "./doc/images/page_white_width.png"
90
- - "./doc/images/plugin.png"
91
- - "./doc/images/ruby.png"
92
- - "./doc/images/tag_blue.png"
93
- - "./doc/images/tag_green.png"
94
- - "./doc/images/transparent.png"
95
- - "./doc/images/wrench.png"
96
- - "./doc/images/wrench_orange.png"
97
- - "./doc/images/zoom.png"
98
- - "./doc/index.html"
99
- - "./doc/js/darkfish.js"
100
- - "./doc/js/navigation.js"
101
- - "./doc/js/navigation.js.gz"
102
- - "./doc/js/search.js"
103
- - "./doc/js/search_index.js"
104
- - "./doc/js/search_index.js.gz"
105
- - "./doc/js/searcher.js"
106
- - "./doc/js/searcher.js.gz"
107
- - "./doc/table_of_contents.html"
108
40
  - "./examples/sign-request/Gemfile"
109
- - "./examples/sign-request/Gemfile.lock"
110
41
  - "./examples/sign-request/README.md"
111
42
  - "./examples/sign-request/main.rb"
112
43
  - "./examples/webhook-server/Gemfile"
@@ -120,6 +51,9 @@ files:
120
51
  - "./lib/truelayer-signing/signer.rb"
121
52
  - "./lib/truelayer-signing/utils.rb"
122
53
  - "./lib/truelayer-signing/verifier.rb"
54
+ - "./test/resources/missing-zero-padding-test-jwks.json"
55
+ - "./test/resources/missing-zero-padding-test-payload.json"
56
+ - "./test/resources/missing-zero-padding-test-signature.txt"
123
57
  - "./test/test-truelayer-signing.rb"
124
58
  - "./truelayer-signing.gemspec"
125
59
  homepage: https://github.com/TrueLayer/truelayer-signing/tree/main/ruby