http_signature 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce69899c93af8245c7a11f207ae55d487ecc360df04c231d5ae1089afe897a6b
4
+ data.tar.gz: c26e3a5d0a06e97b03354cab9f3e20dc0fee3d06aca01ddd03fb55a7ab9c022e
5
+ SHA512:
6
+ metadata.gz: 0b96af0ec445b8b0f298d74e0743035ff2818d85c827892a8366cdf171338b9bf24cd5183b3317de58bcff01e191b60935e0ec4bc84fef7dcf05bc4d6d622666
7
+ data.tar.gz: 5b709409aa43df9606c5fadfc202721664db25005b7935e5aeb62df7755f22b8fd536f13c5972fb321a361d11e6c9e02ef77e96d553bf7766b9417b40958f6c6
File without changes
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rpc.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ http-signature (0.0.2)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ minitest (5.10.3)
10
+ rake (12.2.1)
11
+
12
+ PLATFORMS
13
+ ruby
14
+
15
+ DEPENDENCIES
16
+ bundler
17
+ http-signature!
18
+ minitest
19
+ rake
20
+
21
+ BUNDLED WITH
22
+ 1.16.0
@@ -0,0 +1,168 @@
1
+ # HTTP Signature
2
+ [![CircleCI](https://circleci.com/gh/bolmaster2/http-signature.svg?style=svg)](https://circleci.com/gh/bolmaster2/http-signature)
3
+
4
+ Create and validate HTTP request signature according to this draft: https://tools.ietf.org/html/draft-cavage-http-signatures-08
5
+
6
+ Aims to only implement the creation and validation of the signature without any external dependencies.
7
+ The idea is to implement adapters to popular http libraries to make it easy to use.
8
+
9
+ ## Usage
10
+
11
+ ### Basic
12
+ The most basic usage without any extra headers. The default algorithm is `hmac-sha256`.
13
+ ```ruby
14
+ HTTPSignature.create(
15
+ url: 'https://example.com/foo',
16
+ key_id: 'Test',
17
+ key: 'secret 🙈'
18
+ )
19
+ # 'keyId="Test",algorithm="hmac-sha256",headers="(request-target)",signature="OQ/dHqRW9vFmrW/RCHg7O2Fqx+3uqxJw81p6k9Rcyo4="'
20
+ ```
21
+
22
+ ### With headers, query parameters and a body
23
+ Uses both query parameters (in query string) and a `json` body as a `POST` request.
24
+ Also shows how to set `rsa-sha256` as algorithm. The `digest` is as you see basically
25
+ a `sha-256` digest of the request body.
26
+
27
+ ```ruby
28
+ params = {
29
+ param: 'value',
30
+ pet: 'dog'
31
+ }
32
+
33
+ body = '{"hello": "world"}'
34
+
35
+ headers = {
36
+ 'date': 'Thu, 05 Jan 2014 21:31:40 GMT',
37
+ 'content-type': 'application/json',
38
+ 'digest': HTTPSignature.create_digest(body),
39
+ 'content-length': body.length
40
+ }
41
+
42
+ HTTPSignature.create(
43
+ url: 'https://example.com/foo',
44
+ method: :post,
45
+ query_string_params: params,
46
+ headers: headers,
47
+ key_id: 'Test',
48
+ algorithm: 'rsa-sha256',
49
+ key: File.read('key.pem'),
50
+ body: body
51
+ )
52
+ ```
53
+
54
+ ### With digest header auto-added
55
+ When digest header is omitted it's auto added as last header generated from the `body`:
56
+
57
+ ```ruby
58
+ body = '{"foo": "bar"}'
59
+
60
+ HTTPSignature.create(
61
+ url: 'https://example.com/foo',
62
+ key_id: 'Test',
63
+ key: 'secret 🙈',
64
+ body: body
65
+ )
66
+ # 'keyId="Test",algorithm="hmac-sha256",headers="(request-target) digest",signature="3Jm5jnCSKX3fYLd58RqRdafZKeuSbUEPhn7grCGx4vg="'
67
+ ```
68
+
69
+ ### Validate asymmetric signature
70
+ With an asymmetric algorithm you can't just recreate the same header and see if they
71
+ check out, because you need the private key to do that and because the one validating
72
+ the signature should only have access to the public key, you need to validate it with that.
73
+
74
+ Imagine the incoming HTTP request looks like this:
75
+ ```
76
+ POST /foo HTTP/1.1
77
+ Host: example.com
78
+ Date: Thu, 05 Jan 2014 21:31:40 GMT
79
+ Content-Type: application/json
80
+ Content-Length: 18
81
+ Digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
82
+ Signature: keyId="Test-1",algorithm="rsa-sha256",headers="(request-target) host date content-type content-length digest",signature="YGPVM1tGHD7CHgTmroy9apLtVazdESzMl4vj1koYHNCMmTEDor4Om5TDZDFaJdny5dF3gq+PQQuPwyknNEvACmSjwVXzljPFxaY/JMZTqAdD0yHTP2Rx0Y/J4GwgKARWTZUmccfVYsXp86PhIlCymzleZzYCzj6shyg9NB7Ht+k="
83
+
84
+ {"hello": "world"}
85
+ ```
86
+
87
+ Let's assume we have this request ☝️ in a `request` object for the sake of the example:
88
+ ```ruby
89
+ HTTPSignature.valid?(
90
+ url: request.url,
91
+ method: request.method,
92
+ headers: request.headers,
93
+ body: request.body,
94
+ key: OpenSSL::PKey::RSA.new('public_key.pem'),
95
+ algorithm: 'rsa-sha256'
96
+ )
97
+ ```
98
+
99
+ ## Setup
100
+ ```
101
+ bundle install
102
+ ```
103
+
104
+ ## Test
105
+ The tests are written with `minitest` using specs. Run them all with `rake`:
106
+ ```bash
107
+ rake test
108
+ ```
109
+ Or a single with pattern matching:
110
+ ```bash
111
+ rake test TEST=test/http_signature_test.rb TESTOPTS="--name=/appends\ the\ query_string_params/"
112
+ ```
113
+
114
+ ## Example usage
115
+ ### Faraday middleware on outgoing requests
116
+ Example of using it on an outgoing request.
117
+ ```ruby
118
+ # TODO: Move this into gem
119
+ class AddRequestSignature < Faraday::Middleware
120
+ def call(env)
121
+ if env[:body]
122
+ env[:request_headers].merge!('Digest' => HTTPSignature.create_digest(env[:body]))
123
+ end
124
+
125
+ # Choose which headers to sign
126
+ headers_filter = %w{ Host Date Digest }
127
+ headers_to_sign = env[:request_headers].select { |k, v| headers_filter.include?(k.to_s) }
128
+
129
+ signature = HTTPSignature.create(
130
+ url: env[:url],
131
+ method: env[:method],
132
+ headers: headers_to_sign,
133
+ key: ENV.fetch('REQUEST_SIGNATURE_KEY'),
134
+ key_id: ENV.fetch('REQUEST_SIGNATURE_KEY_ID'),
135
+ algorithm: 'hmac-sha256',
136
+ body: env[:body] ? env[:body] : ''
137
+ )
138
+
139
+ env[:request_headers].merge!('Signature' => signature)
140
+
141
+ @app.call(env)
142
+ end
143
+ end
144
+
145
+ # Tell faraday to use the middleware. Read more about it here: https://github.com/lostisland/faraday#advanced-middleware-usage
146
+ Faraday.new('http://example.com') do |faraday|
147
+ faraday.use(AddRequestSignature)
148
+ faraday.adapter(Faraday.default_adapter)
149
+ end
150
+
151
+ response = conn.get('/')
152
+ ```
153
+
154
+ ### Rack middleware
155
+ I've written a quite sloppy but totally usable rack middleware that validates every incoming request.
156
+ [See it here](examples/rack_middleware.rb). Soon I'll add it to the gem.
157
+
158
+ ## License
159
+ This project is licensed under the terms of the [MIT license](https://opensource.org/licenses/MIT).
160
+
161
+ ## Todo
162
+ - Structure and add middlewares into gem
163
+ - Add more example of use with different http libraries
164
+ - Implement algorithms:
165
+ - ecdsa-sha256
166
+ - When creating the signing string, follow the spec exactly:
167
+ https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-2.3,
168
+ e.g, concatenate multiple instances of the same headers and remove surrounding whitespaces
@@ -0,0 +1,8 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ t.pattern = "test/**/*_test.rb"
7
+ t.verbose = false
8
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_signature'
4
+
5
+ class AddRequestSignature < Faraday::Middleware
6
+ def call(env)
7
+ if env[:body]
8
+ env[:request_headers].merge!('Digest' => HTTPSignature.create_digest(env[:body]))
9
+ end
10
+
11
+ # Choose which headers to sign
12
+ filtered_headers = %w{ Host Date Digest }
13
+ headers_to_sign = env[:request_headers].select { |k, v| filtered_headers.include?(k.to_s) }
14
+
15
+ headers.select { |header| headers_to_sign.includes(header) }.to_h
16
+
17
+ signature = HTTPSignature.create(
18
+ url: env[:url],
19
+ method: env[:method],
20
+ headers: headers,
21
+ key: ENV.fetch('REQUEST_SIGNATURE_KEY'),
22
+ key_id: ENV.fetch('REQUEST_SIGNATURE_KEY_ID'),
23
+ algorithm: 'hmac-sha256',
24
+ body: env[:body] ? env[:body] : ''
25
+ )
26
+
27
+ env[:request_headers].merge!('Signature' => signature)
28
+
29
+ @app.call(env)
30
+ end
31
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_signature'
4
+
5
+ # Rack middleware using http-signature gem to validate signature on every incoming request
6
+ class ValidateRequestSignature
7
+ KEY = ENV.fetch('REQUEST_SIGNATURE_KEY')
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ request = Rack::Request.new(env)
15
+ return [401, {}, ['No signature header']] unless request.get_header("HTTP_SIGNATURE")
16
+
17
+ request_body = request.body.gets
18
+ request_headers = parse_request_headers(request)
19
+ begin
20
+ parsed_signature = parse_signature(request_headers)
21
+ rescue
22
+ return [401, {}, ['Invalid signature :(']]
23
+ end
24
+ headers_to_sign = request_headers.select { |k, v| parsed_signature['headers'].include?(k) }
25
+
26
+ params = {
27
+ url: request.path,
28
+ method: request.request_method,
29
+ headers: headers_to_sign,
30
+ key: KEY,
31
+ key_id: parsed_signature['keyId'],
32
+ algorithm: parsed_signature['algorithm'],
33
+ body: request_body ? request_body : '',
34
+ query_string_params: Rack::Utils.parse_nested_query(request.query_string)
35
+ }
36
+
37
+ valid_signature =
38
+ if parsed_signature['algorithm'].include?('rsa')
39
+ HTTPSignature.valid?(**params)
40
+ else
41
+ HTTPSignature.create(**params) == request_headers['signature']
42
+ end
43
+
44
+ if valid_signature
45
+ @app.call(env)
46
+ else
47
+ [401, {}, ['Invalid signature :(']]
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parse_request_headers(request)
54
+ request_headers = {}
55
+
56
+ request.each_header do |header|
57
+ if header[0].include?('HTTP_') && header[0] != 'HTTP_VERSION'
58
+ request_headers[header[0].gsub('HTTP_', '').gsub("_", "-").downcase] = header[1]
59
+ end
60
+ end
61
+
62
+ request_headers
63
+ end
64
+
65
+ def parse_signature(request_headers)
66
+ Rack::Utils.parse_nested_query(
67
+ request_headers['signature'].gsub(',', '&')
68
+ ).map do |k, v|
69
+ [k, v.tr('"', '')]
70
+ end.to_h
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = 'http_signature'
6
+ spec.version = '0.0.2'
7
+ spec.authors = ['Joel Larsson']
8
+ spec.email = ['bolmaster2@gmail.com']
9
+
10
+ spec.summary = 'Create and validate HTTP request signature'
11
+ spec.description = 'Create and validate HTTP request signature according to this draft: https://tools.ietf.org/html/draft-cavage-http-signatures-08'
12
+ spec.homepage = 'https://github.com/bolmaster2/http-signature'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = 'exe'
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler'
21
+ spec.add_development_dependency 'rake'
22
+ spec.add_development_dependency 'minitest'
23
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'base64'
6
+ require 'uri'
7
+
8
+ module HTTPSignature
9
+ # Create signature based on the data sent in
10
+ #
11
+ # @param url [String] Full request url, can include query string as well
12
+ # @param query_string_params [Hash] Query string parameters, appends params to
13
+ # url if query string is already found in it
14
+ # @param body [String] Request body as a string, i.e., the "raw" request body
15
+ # @param headers [Hash] Request headers to include in the signature
16
+ # @param key [String] Key/secret that is used by the corresponding `algorithm`
17
+ # @param key_id [String] Key id
18
+ # @param method [Symbol] Request method, default is `:get`
19
+ # @param algorithm [String] Algorithm to use when signing, check `supported_algorithms` for
20
+ # @return [String] The signature header value to use in "Signature" header
21
+ def self.create(url:, query_string_params: {}, body: '', headers: {}, key:,
22
+ key_id: SecureRandom.hex(8),
23
+ method: :get,
24
+ algorithm: 'hmac-sha256'
25
+ )
26
+
27
+ raise 'Unsupported algorithm :(' unless supported_algorithms.include?(algorithm)
28
+
29
+ uri = URI(url)
30
+ path = uri.path
31
+ headers = add_digest(headers, body)
32
+ headers = convert_headers(headers)
33
+ query = create_query_string(uri, query_string_params)
34
+
35
+ string_to_sign = create_signing_string(method: method, path: path,
36
+ query: query, headers: headers)
37
+
38
+ signature = sign(string_to_sign, key: key, algorithm: algorithm)
39
+ create_signature_header(key_id: key_id, headers: headers, signature: signature,
40
+ algorithm: algorithm)
41
+ end
42
+
43
+ def self.sign(string, key:, algorithm:)
44
+ case algorithm
45
+ when 'hmac-sha256'
46
+ OpenSSL::HMAC.digest('SHA256', key, string)
47
+ when 'hmac-sha512'
48
+ OpenSSL::HMAC.digest('SHA512', key, string)
49
+ when 'rsa-sha256'
50
+ k = OpenSSL::PKey::RSA.new(key)
51
+ k.sign(OpenSSL::Digest::SHA256.new, string)
52
+ when 'rsa-sha512'
53
+ k = OpenSSL::PKey::RSA.new(key)
54
+ k.sign(OpenSSL::Digest::SHA512.new, string)
55
+ end
56
+ end
57
+
58
+ def self.create_signature_header(key_id:, headers: [], signature:, algorithm:)
59
+ headers = headers.map { |h| h.split(':').first }
60
+ header_fields = ['(request-target)'].concat(headers).join(' ')
61
+
62
+ [
63
+ "keyId=\"#{key_id}\"",
64
+ "algorithm=\"#{algorithm}\"",
65
+ "headers=\"#{header_fields}\"",
66
+ "signature=\"#{Base64.strict_encode64(signature)}\""
67
+ ].join(',')
68
+ end
69
+
70
+ # TODO: Support them all: rsa-sha1, rsa-sha512, dsa-sha1, hmac-sha1
71
+ def self.supported_algorithms
72
+ ['hmac-sha256', 'hmac-sha512', 'rsa-sha256', 'rsa-sha512']
73
+ end
74
+
75
+ # Create the digest header based on the request body
76
+ # @param body [String] Raw request body string
77
+ # @return [String] SHA256 and base64 digested string with prefix: 'SHA-256='
78
+ def self.create_digest(body)
79
+ 'SHA-256=' + Digest::SHA256.base64digest(body)
80
+ end
81
+
82
+ # Creates the string to sign
83
+ # See details here: https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-2.3
84
+ # TODO: Concatenate multiple instances of the same headers
85
+ # Also remove leading and trailing whitespace
86
+ # @return [String]
87
+ def self.create_signing_string(method:, path:, query:, headers:)
88
+ [
89
+ "(request-target): #{method.upcase} #{path}#{query}",
90
+ ].concat(headers).join("\n")
91
+ end
92
+
93
+ # Check if signature is valid. Using the exact same parameters as .create
94
+ # minus `key_id`
95
+ #
96
+ # @param url [String] Full request url, can include query string as well
97
+ # @param query_string_params [Hash] Query string parameters, appends params to
98
+ # url if query string is already found in it
99
+ # @param body [String] Request body as a string, i.e., the "raw" request body
100
+ # @param headers [Hash] Request headers to include in the signature
101
+ # @param key [String] Key/secret that is used by the corresponding `algorithm`
102
+ # @param method [Symbol] Request method, default is `:get`
103
+ # @param algorithm [String] Algorithm to use when signing, check `supported_algorithms` for
104
+ # @return [Boolean] Valid or not, Crypto is kinda binary in this case :)
105
+ def self.valid?(url:, query_string_params: {}, body: '', headers: {}, key:, method:, algorithm:)
106
+ raise 'Key needs to be public' unless key.public?
107
+
108
+ # TODO: A lot of the code here is exactly as `.create`, i.e., this could be DRYed :point_down:
109
+ uri = URI(url)
110
+ path = uri.path
111
+ signature = headers.delete(:signature)
112
+ headers = convert_headers(headers)
113
+ query = create_query_string(uri, query_string_params)
114
+
115
+ string_to_sign = create_signing_string(
116
+ method: method, path: path, query: query, headers: headers
117
+ )
118
+
119
+ key.verify(
120
+ get_digest(algorithm), get_signature_from_header(signature), string_to_sign
121
+ )
122
+ end
123
+
124
+ # Maps algoritgm string to digest object
125
+ # @param algorithm [String]
126
+ # @return [OpenSSL::Digest] Instance of `OpenSSL::Digest::SHA256` or OpenSSL::Digest::SHA512
127
+ def self.get_digest(algorithm)
128
+ {
129
+ 'rsa-sha256' => OpenSSL::Digest::SHA256.new,
130
+ 'rsa-sha512' => OpenSSL::Digest::SHA512.new
131
+ }[algorithm]
132
+ end
133
+
134
+ # Extract the actual signature from the whole "Signature" header
135
+ # @param header [String]
136
+ # @return [String]
137
+ def self.get_signature_from_header(header)
138
+ Base64.strict_decode64(header.match(/signature\=\"(.*)\"/)[1])
139
+ end
140
+
141
+ # When query string params is also set on the url, append the params defined
142
+ # in `query_string_params` and make a joint query string
143
+ def self.create_query_string(uri, query_string_params)
144
+ if uri.query || !query_string_params.empty?
145
+ delimiter = uri.query.nil? ? '' : '&'
146
+ '?' + (query_string_params.empty? ? '' : [uri.query.to_s, delimiter, URI.encode_www_form(query_string_params)].join)
147
+ end
148
+ end
149
+ # Convert a header hash into an array with header strings
150
+ # { header: 'value'} -> ['header: value']
151
+ def self.convert_headers(headers)
152
+ headers.map do |key, value|
153
+ [key.to_s.downcase.strip, value.strip].join(': ')
154
+ end
155
+ end
156
+
157
+ def self.add_digest(headers, body)
158
+ headers[:digest] = create_digest(body) unless body.empty?
159
+
160
+ headers
161
+ end
162
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: http_signature
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Joel Larsson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-04-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: 'Create and validate HTTP request signature according to this draft:
56
+ https://tools.ietf.org/html/draft-cavage-http-signatures-08'
57
+ email:
58
+ - bolmaster2@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".circleci/config.yml"
64
+ - Gemfile
65
+ - Gemfile.lock
66
+ - README.md
67
+ - Rakefile
68
+ - examples/faraday_middleware.rb
69
+ - examples/rack_middleware.rb
70
+ - http_signature.gemspec
71
+ - lib/http_signature.rb
72
+ homepage: https://github.com/bolmaster2/http-signature
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubyforge_project:
92
+ rubygems_version: 2.7.3
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Create and validate HTTP request signature
96
+ test_files: []