truelayer-signing 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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