truelayer-signing 0.1.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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE-APACHE +176 -0
  4. data/LICENSE-MIT +21 -0
  5. data/README.md +82 -0
  6. data/Rakefile +8 -0
  7. data/doc/CHANGELOG_md.html +132 -0
  8. data/doc/JWT/Decode.html +97 -0
  9. data/doc/JWT/Encode.html +97 -0
  10. data/doc/JWT/JWK/EC.html +169 -0
  11. data/doc/JWT/JWK.html +91 -0
  12. data/doc/JWT.html +95 -0
  13. data/doc/LICENSE-APACHE.html +177 -0
  14. data/doc/LICENSE-MIT.html +105 -0
  15. data/doc/README_md.html +197 -0
  16. data/doc/Rakefile.html +106 -0
  17. data/doc/TrueLayerSigning/Config.html +211 -0
  18. data/doc/TrueLayerSigning/Error.html +97 -0
  19. data/doc/TrueLayerSigning/JwsBase.html +317 -0
  20. data/doc/TrueLayerSigning/JwsHeader.html +268 -0
  21. data/doc/TrueLayerSigning/Signer.html +186 -0
  22. data/doc/TrueLayerSigning/Verifier.html +327 -0
  23. data/doc/TrueLayerSigning.html +226 -0
  24. data/doc/TrueLayerSigningExamples.html +217 -0
  25. data/doc/created.rid +21 -0
  26. data/doc/css/fonts.css +167 -0
  27. data/doc/css/rdoc.css +662 -0
  28. data/doc/examples/sign-request/Gemfile.html +99 -0
  29. data/doc/examples/sign-request/Gemfile_lock.html +143 -0
  30. data/doc/examples/sign-request/README_md.html +138 -0
  31. data/doc/examples/webhook-server/Gemfile.html +99 -0
  32. data/doc/examples/webhook-server/Gemfile_lock.html +142 -0
  33. data/doc/examples/webhook-server/README_md.html +139 -0
  34. data/doc/fonts/Lato-Light.ttf +0 -0
  35. data/doc/fonts/Lato-LightItalic.ttf +0 -0
  36. data/doc/fonts/Lato-Regular.ttf +0 -0
  37. data/doc/fonts/Lato-RegularItalic.ttf +0 -0
  38. data/doc/fonts/SourceCodePro-Bold.ttf +0 -0
  39. data/doc/fonts/SourceCodePro-Regular.ttf +0 -0
  40. data/doc/images/add.png +0 -0
  41. data/doc/images/arrow_up.png +0 -0
  42. data/doc/images/brick.png +0 -0
  43. data/doc/images/brick_link.png +0 -0
  44. data/doc/images/bug.png +0 -0
  45. data/doc/images/bullet_black.png +0 -0
  46. data/doc/images/bullet_toggle_minus.png +0 -0
  47. data/doc/images/bullet_toggle_plus.png +0 -0
  48. data/doc/images/date.png +0 -0
  49. data/doc/images/delete.png +0 -0
  50. data/doc/images/find.png +0 -0
  51. data/doc/images/loadingAnimation.gif +0 -0
  52. data/doc/images/macFFBgHack.png +0 -0
  53. data/doc/images/package.png +0 -0
  54. data/doc/images/page_green.png +0 -0
  55. data/doc/images/page_white_text.png +0 -0
  56. data/doc/images/page_white_width.png +0 -0
  57. data/doc/images/plugin.png +0 -0
  58. data/doc/images/ruby.png +0 -0
  59. data/doc/images/tag_blue.png +0 -0
  60. data/doc/images/tag_green.png +0 -0
  61. data/doc/images/transparent.png +0 -0
  62. data/doc/images/wrench.png +0 -0
  63. data/doc/images/wrench_orange.png +0 -0
  64. data/doc/images/zoom.png +0 -0
  65. data/doc/index.html +118 -0
  66. data/doc/js/darkfish.js +84 -0
  67. data/doc/js/navigation.js +105 -0
  68. data/doc/js/navigation.js.gz +0 -0
  69. data/doc/js/search.js +110 -0
  70. data/doc/js/search_index.js +1 -0
  71. data/doc/js/search_index.js.gz +0 -0
  72. data/doc/js/searcher.js +229 -0
  73. data/doc/js/searcher.js.gz +0 -0
  74. data/doc/table_of_contents.html +269 -0
  75. data/examples/sign-request/Gemfile +4 -0
  76. data/examples/sign-request/Gemfile.lock +41 -0
  77. data/examples/sign-request/README.md +27 -0
  78. data/examples/sign-request/main.rb +46 -0
  79. data/examples/webhook-server/Gemfile +3 -0
  80. data/examples/webhook-server/Gemfile.lock +15 -0
  81. data/examples/webhook-server/README.md +30 -0
  82. data/examples/webhook-server/main.rb +98 -0
  83. data/lib/truelayer-signing/config.rb +21 -0
  84. data/lib/truelayer-signing/errors.rb +3 -0
  85. data/lib/truelayer-signing/jwt.rb +20 -0
  86. data/lib/truelayer-signing/signer.rb +34 -0
  87. data/lib/truelayer-signing/utils.rb +90 -0
  88. data/lib/truelayer-signing/verifier.rb +76 -0
  89. data/lib/truelayer-signing.rb +35 -0
  90. data/test/test-truelayer-signing.rb +372 -0
  91. data/truelayer-signing.gemspec +25 -0
  92. metadata +151 -0
@@ -0,0 +1,98 @@
1
+ require "socket"
2
+ require "truelayer-signing"
3
+
4
+ class TrueLayerSigningExamples
5
+ # Note: the webhook path can be whatever is configured for your application.
6
+ # Here a unique path is used, matching the example signature in the README.
7
+ WEBHOOK_PATH = "/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b".freeze
8
+ PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\n" +
9
+ "MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBJ6ET9XeVCyMy+yOetZaNNCXPhwr5\n" +
10
+ "BlyDDg1CLmyNM5SvqOs8RveL6dYl4lpPur4xrPQl04ggYlVd9wnHkZnp3jcBlXw8\n" +
11
+ "Lc5phyYF1q2/QV/5wp2WHIhKDqUiXC0TvlE8d7MdTAN9yolcwrh6aWZ3kesTMZif\n" +
12
+ "BgItyT6PXUab8mMdI8k=\n-----END PUBLIC KEY-----"
13
+
14
+ class << self
15
+ def run_webhook_server
16
+ server = TCPServer.new(4567)
17
+
18
+ puts "Server running at http://localhost:4567"
19
+
20
+ loop do
21
+ Thread.start(server.accept) do |client|
22
+ begin
23
+ request = parse_request(client)
24
+ status, body = handle_request.call(request)
25
+ headers = { "Content-Type" => "text/plain" }
26
+
27
+ send_response(client, status, headers, body)
28
+ rescue => error
29
+ puts error
30
+ ensure
31
+ client.close
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ private def parse_request(client)
38
+ request = client.gets
39
+ headers, body = client.readpartial(2048).split("\r\n\r\n", 2)
40
+ method, remainder = request.split(" ", 2)
41
+ url = remainder.split(" ").first
42
+ path, _query_strings = url.split("?", 2)
43
+
44
+ {
45
+ method: method,
46
+ path: path,
47
+ headers: headers_to_hash(headers),
48
+ body: body
49
+ }
50
+ end
51
+
52
+ private def handle_request
53
+ Proc.new do |request|
54
+ if request[:method] == "POST" and request[:path] == WEBHOOK_PATH
55
+ verify_webhook(request[:path], request[:headers], request[:body])
56
+ else
57
+ ["403", "Forbidden"]
58
+ end
59
+ end
60
+ end
61
+
62
+ private def verify_webhook(path, headers, body)
63
+ tl_signature = headers["tl-signature"]
64
+
65
+ return ["400", "Bad Request – Header `Tl-Signature` missing"] unless tl_signature
66
+
67
+ begin
68
+ TrueLayerSigning.verify_with_pem(PUBLIC_KEY_PEM)
69
+ .set_method(:post)
70
+ .set_path(path)
71
+ .set_headers(headers)
72
+ .set_body(body)
73
+ .verify(tl_signature)
74
+
75
+ ["202", "Accepted"]
76
+ rescue TrueLayerSigning::Error
77
+ ["401", "Unauthorized"]
78
+ end
79
+ end
80
+
81
+ private def send_response(client, status, headers, body)
82
+ client.print("HTTP/1.1 #{status}\r\n")
83
+ headers.each { |key, value| client.print("#{key}: #{value}\r\n") }
84
+ client.print("\r\n#{body}")
85
+ end
86
+
87
+ private def headers_to_hash(headers_string, hash = Hash.new)
88
+ headers_string.split("\r\n").each do |line|
89
+ pair = line.split(":")
90
+ hash[pair.first.to_s.strip.downcase] = pair.last.to_s.strip
91
+ end
92
+
93
+ hash
94
+ end
95
+ end
96
+ end
97
+
98
+ TrueLayerSigningExamples.run_webhook_server
@@ -0,0 +1,21 @@
1
+ module TrueLayerSigning
2
+ class Config
3
+ attr_accessor :certificate_id, :private_key
4
+ attr_reader :algorithm, :version
5
+
6
+ # @return [TrueLayerSigning::Config]
7
+ def self.setup
8
+ new.tap do |instance|
9
+ yield(instance) if block_given?
10
+ end
11
+ end
12
+
13
+ # @return [TrueLayerSigning::Config]
14
+ def initialize
15
+ @algorithm = "ES512".freeze
16
+ @certificate_id = ENV.fetch("TRUELAYER_SIGNING_CERTIFICATE_ID", nil).freeze
17
+ @private_key = ENV.fetch("TRUELAYER_SIGNING_PRIVATE_KEY", nil)&.gsub(/\\n/, "\n").freeze
18
+ @version = "2".freeze
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module TrueLayerSigning
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,20 @@
1
+ # TODO: this is a custom patch of payload-related methods, from the 'jwt' gem.
2
+ # It prevents the payload from being systematically converted to and from JSON.
3
+ # To be changed in the 'jwt' gem directly, or hard-coded in this library.
4
+ module JWT
5
+ class Encode
6
+ # See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/encode.rb#L53-L55
7
+ private def encode_payload
8
+ ::JWT::Base64.url_encode(@payload)
9
+ end
10
+ end
11
+
12
+ class Decode
13
+ # See https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/decode.rb#L154-L156
14
+ private def payload
15
+ @payload ||= ::JWT::Base64.url_decode(@segments[1])
16
+ rescue ::JSON::ParserError
17
+ raise JWT::DecodeError, 'Invalid segment encoding'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ module TrueLayerSigning
2
+ class Signer < JwsBase
3
+ attr_reader :jws_jku
4
+
5
+ def sign
6
+ ensure_signer_config!
7
+
8
+ private_key = OpenSSL::PKey.read(TrueLayerSigning.private_key)
9
+ jws_header_args = { tl_headers: headers }
10
+ jws_header_args[:jku] = jws_jku if jws_jku
11
+ jws_header = TrueLayerSigning::JwsHeader.new(jws_header_args).to_h
12
+ jwt = JWT.encode(build_signing_payload, private_key, TrueLayerSigning.algorithm, jws_header)
13
+ header, _, signature = jwt.split(".")
14
+
15
+ "#{header}..#{signature}"
16
+ end
17
+
18
+ def set_jku(jku)
19
+ @jws_jku = jku
20
+ self
21
+ end
22
+
23
+ private def ensure_signer_config!
24
+ raise(Error, "TRUELAYER_SIGNING_CERTIFICATE_ID missing") \
25
+ if TrueLayerSigning.certificate_id.nil? ||
26
+ TrueLayerSigning.certificate_id.empty?
27
+ raise(Error, "TRUELAYER_SIGNING_PRIVATE_KEY missing") \
28
+ if TrueLayerSigning.private_key.nil? ||
29
+ TrueLayerSigning.private_key.empty?
30
+ raise(Error, "Request path missing") unless path
31
+ raise(Error, "Request body missing") unless body
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,90 @@
1
+ module TrueLayerSigning
2
+ class JwsHeader
3
+ attr_reader :alg, :kid, :tl_version, :tl_headers, :jku
4
+
5
+ def initialize(args = {})
6
+ raise(Error, "TRUELAYER_SIGNING_CERTIFICATE_ID is missing") \
7
+ if TrueLayerSigning.certificate_id.nil? ||
8
+ TrueLayerSigning.certificate_id.empty?
9
+
10
+ @alg = args[:alg] || TrueLayerSigning.algorithm
11
+ @kid = args[:kid] || TrueLayerSigning.certificate_id
12
+ @tl_version = TrueLayerSigning.version
13
+ @tl_headers = retrieve_headers(args[:tl_headers])
14
+ @jku = args[:jku] || nil
15
+ end
16
+
17
+ def to_h
18
+ hash = instance_variables.map { |var| [var[1..-1].to_sym, instance_variable_get(var)] }.to_h
19
+ hash.reject { |key, _value| hash[key].nil? }
20
+ end
21
+
22
+ def filter_headers(headers)
23
+ required_header_keys = tl_headers.split(",").reject { |key| key.empty? }
24
+ normalised_headers = {}
25
+ headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
26
+
27
+ ordered_headers = required_header_keys.map do |key|
28
+ value = normalised_headers[key.downcase]
29
+
30
+ raise(Error, "Missing header(s) declared in signature") unless value
31
+
32
+ [key, value]
33
+ end
34
+
35
+ ordered_headers.to_h
36
+ end
37
+
38
+ private def retrieve_headers(tl_headers)
39
+ tl_headers && tl_headers.is_a?(Hash) && tl_headers.keys.join(",") || tl_headers || ""
40
+ end
41
+ end
42
+
43
+ class JwsBase
44
+ attr_reader :method, :path, :headers, :body
45
+
46
+ def initialize(args = {})
47
+ @method = "POST"
48
+ @headers = {}
49
+ end
50
+
51
+ def set_method(method)
52
+ @method = method.to_s.upcase
53
+ self
54
+ end
55
+
56
+ def set_path(path)
57
+ raise(Error, "Path must start with '/'") unless path.start_with?("/")
58
+
59
+ @path = path
60
+ self
61
+ end
62
+
63
+ def add_header(name, value)
64
+ @headers[name.to_s] = value
65
+ self
66
+ end
67
+
68
+ def set_headers(headers)
69
+ headers.each { |name, value| @headers[name.to_s] = value }
70
+ self
71
+ end
72
+
73
+ def set_body(body)
74
+ @body = body
75
+ self
76
+ end
77
+
78
+ private def build_signing_payload(custom_headers = nil)
79
+ parts = []
80
+ parts.push("#{method.upcase} #{path}")
81
+ parts.push(custom_headers && format_headers(custom_headers) || format_headers(headers))
82
+ parts.push(body)
83
+ parts.reject { |elem| elem.nil? || elem.empty? }.join("\n")
84
+ end
85
+
86
+ private def format_headers(headers)
87
+ headers.map { |key, value| "#{key}: #{value}" }.join("\n")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ module TrueLayerSigning
2
+ class Verifier < JwsBase
3
+ attr_reader :required_headers, :key_value
4
+
5
+ def initialize(args)
6
+ super
7
+ @key_value = args[:key_value]
8
+ end
9
+
10
+ def verify(tl_signature)
11
+ ensure_verifier_config!
12
+
13
+ jws_header, jws_header_b64, signature_b64 = self.class.parse_tl_signature(tl_signature)
14
+ public_key = OpenSSL::PKey.read(key_value)
15
+
16
+ raise(Error, "Unexpected `alg` header value") if jws_header.alg != TrueLayerSigning.algorithm
17
+
18
+ ordered_headers = jws_header.filter_headers(headers)
19
+ normalised_headers = {}
20
+ ordered_headers.to_a.each { |header| normalised_headers[header.first.downcase] = header.last }
21
+
22
+ raise(Error, "Signature missing required header(s)") if required_headers &&
23
+ required_headers.any? { |key| !normalised_headers.has_key?(key.downcase) }
24
+
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.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.decode(full_signature, public_key, true, jwt_options)
39
+ rescue
40
+ raise(Error, "Signature verification failed")
41
+ end
42
+ end
43
+ end
44
+
45
+ def require_header(name)
46
+ @required_headers ||= []
47
+ @required_headers.push(name)
48
+ self
49
+ end
50
+
51
+ def require_headers(names)
52
+ @required_headers = names
53
+ self
54
+ end
55
+
56
+ def self.parse_tl_signature(tl_signature)
57
+ jws_header_b64, signature_b64 = tl_signature.split("..")
58
+
59
+ raise(Error, "Invalid signature format") unless signature_b64
60
+
61
+ begin
62
+ jws_header_raw = Base64.urlsafe_decode64(jws_header_b64)
63
+ rescue ArgumentError
64
+ raise(Error, "Invalid base64 for header")
65
+ else
66
+ jws_header = JwsHeader.new(JSON.parse(jws_header_raw, symbolize_names: true))
67
+ end
68
+
69
+ [jws_header, jws_header_b64, signature_b64]
70
+ end
71
+
72
+ private def ensure_verifier_config!
73
+ raise(Error, "Key value missing") unless key_value
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ require "base64"
2
+ require "forwardable"
3
+ require "jwt"
4
+
5
+ # TODO: replace with a proper solution
6
+ require "truelayer-signing/jwt"
7
+
8
+ require "truelayer-signing/config"
9
+ require "truelayer-signing/errors"
10
+ require "truelayer-signing/utils"
11
+ require "truelayer-signing/signer"
12
+ require "truelayer-signing/verifier"
13
+
14
+ module TrueLayerSigning
15
+ @config = Config.setup
16
+
17
+ class << self
18
+ extend Forwardable
19
+
20
+ attr_reader :config
21
+
22
+ def_delegators :@config, :certificate_id, :certificate_id=
23
+ def_delegators :@config, :private_key, :private_key=
24
+ def_delegator :@config, :algorithm
25
+ def_delegator :@config, :version
26
+
27
+ def sign_with_pem
28
+ Signer.new
29
+ end
30
+
31
+ def verify_with_pem(pem)
32
+ Verifier.new(key_value: pem)
33
+ end
34
+ end
35
+ end