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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE-APACHE +176 -0
- data/LICENSE-MIT +21 -0
- data/README.md +82 -0
- data/Rakefile +8 -0
- data/doc/CHANGELOG_md.html +132 -0
- data/doc/JWT/Decode.html +97 -0
- data/doc/JWT/Encode.html +97 -0
- data/doc/JWT/JWK/EC.html +169 -0
- data/doc/JWT/JWK.html +91 -0
- data/doc/JWT.html +95 -0
- data/doc/LICENSE-APACHE.html +177 -0
- data/doc/LICENSE-MIT.html +105 -0
- data/doc/README_md.html +197 -0
- data/doc/Rakefile.html +106 -0
- data/doc/TrueLayerSigning/Config.html +211 -0
- data/doc/TrueLayerSigning/Error.html +97 -0
- data/doc/TrueLayerSigning/JwsBase.html +317 -0
- data/doc/TrueLayerSigning/JwsHeader.html +268 -0
- data/doc/TrueLayerSigning/Signer.html +186 -0
- data/doc/TrueLayerSigning/Verifier.html +327 -0
- data/doc/TrueLayerSigning.html +226 -0
- data/doc/TrueLayerSigningExamples.html +217 -0
- data/doc/created.rid +21 -0
- data/doc/css/fonts.css +167 -0
- data/doc/css/rdoc.css +662 -0
- data/doc/examples/sign-request/Gemfile.html +99 -0
- data/doc/examples/sign-request/Gemfile_lock.html +143 -0
- data/doc/examples/sign-request/README_md.html +138 -0
- data/doc/examples/webhook-server/Gemfile.html +99 -0
- data/doc/examples/webhook-server/Gemfile_lock.html +142 -0
- data/doc/examples/webhook-server/README_md.html +139 -0
- data/doc/fonts/Lato-Light.ttf +0 -0
- data/doc/fonts/Lato-LightItalic.ttf +0 -0
- data/doc/fonts/Lato-Regular.ttf +0 -0
- data/doc/fonts/Lato-RegularItalic.ttf +0 -0
- data/doc/fonts/SourceCodePro-Bold.ttf +0 -0
- data/doc/fonts/SourceCodePro-Regular.ttf +0 -0
- data/doc/images/add.png +0 -0
- data/doc/images/arrow_up.png +0 -0
- data/doc/images/brick.png +0 -0
- data/doc/images/brick_link.png +0 -0
- data/doc/images/bug.png +0 -0
- data/doc/images/bullet_black.png +0 -0
- data/doc/images/bullet_toggle_minus.png +0 -0
- data/doc/images/bullet_toggle_plus.png +0 -0
- data/doc/images/date.png +0 -0
- data/doc/images/delete.png +0 -0
- data/doc/images/find.png +0 -0
- data/doc/images/loadingAnimation.gif +0 -0
- data/doc/images/macFFBgHack.png +0 -0
- data/doc/images/package.png +0 -0
- data/doc/images/page_green.png +0 -0
- data/doc/images/page_white_text.png +0 -0
- data/doc/images/page_white_width.png +0 -0
- data/doc/images/plugin.png +0 -0
- data/doc/images/ruby.png +0 -0
- data/doc/images/tag_blue.png +0 -0
- data/doc/images/tag_green.png +0 -0
- data/doc/images/transparent.png +0 -0
- data/doc/images/wrench.png +0 -0
- data/doc/images/wrench_orange.png +0 -0
- data/doc/images/zoom.png +0 -0
- data/doc/index.html +118 -0
- data/doc/js/darkfish.js +84 -0
- data/doc/js/navigation.js +105 -0
- data/doc/js/navigation.js.gz +0 -0
- data/doc/js/search.js +110 -0
- data/doc/js/search_index.js +1 -0
- data/doc/js/search_index.js.gz +0 -0
- data/doc/js/searcher.js +229 -0
- data/doc/js/searcher.js.gz +0 -0
- data/doc/table_of_contents.html +269 -0
- data/examples/sign-request/Gemfile +4 -0
- data/examples/sign-request/Gemfile.lock +41 -0
- data/examples/sign-request/README.md +27 -0
- data/examples/sign-request/main.rb +46 -0
- data/examples/webhook-server/Gemfile +3 -0
- data/examples/webhook-server/Gemfile.lock +15 -0
- data/examples/webhook-server/README.md +30 -0
- data/examples/webhook-server/main.rb +98 -0
- data/lib/truelayer-signing/config.rb +21 -0
- data/lib/truelayer-signing/errors.rb +3 -0
- data/lib/truelayer-signing/jwt.rb +20 -0
- data/lib/truelayer-signing/signer.rb +34 -0
- data/lib/truelayer-signing/utils.rb +90 -0
- data/lib/truelayer-signing/verifier.rb +76 -0
- data/lib/truelayer-signing.rb +35 -0
- data/test/test-truelayer-signing.rb +372 -0
- data/truelayer-signing.gemspec +25 -0
- 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,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
|