wt_s3_signer 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 (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/wt_s3_signer.rb +183 -0
  3. metadata +131 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 99d14080be11be7b5aeed75a44dba5001d2bcff106c5277ec679666381efe5c5
4
+ data.tar.gz: dfa138218f64787226846defd64051c76ad65d95b81d420919357a7366cfcda3
5
+ SHA512:
6
+ metadata.gz: cea9671eff80aa7ae3919f6e3a684238191329cb694857eb33c1478603078c77f62a8d1c62357f804014f4c69329814fe35a0eca9a84f5101b7e7f99e7ba6ebd
7
+ data.tar.gz: df2fea55f7c659d08cf8184da7d9b921e34d575e4b0731a6f4a50ac6230c64c7a501123f1711e8a1ba87877da42527aaeb9636a00927265222ccc76dba12b6cc
@@ -0,0 +1,183 @@
1
+ require 'openssl'
2
+ require 'digest'
3
+ require 'cgi'
4
+
5
+ # An accelerated version of the reference implementation ported
6
+ # from Python, see here:
7
+ #
8
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
9
+ #
10
+ # The optimisation in comparison to the ref implementation
11
+ # is that everything that can be computed once gets computed for the
12
+ # first signature being generated, and then reused. This includes
13
+ # the timestamp and everything derived from it, the signing key
14
+ # and the query string (before the signature is computed).
15
+ #
16
+ # Note that this is specifically made for the cases where one needs
17
+ # presigned URLs for multiple objects from the same bucket, with the same
18
+ # expiry. Passing the expiry via the constructor, for instance, allows us
19
+ # to cache more of the query string - saving even more time.
20
+ module WT
21
+ class S3Signer
22
+
23
+ # Creates a new instance of WT::S3Signer for a given S3 bucket object.
24
+ # This object can be created in the AWS SDK using `Aws::S3::Bucket.new(my_bucket_name)`.
25
+ # The bucket object helps resolving the bucket endpoint URL, determining the bucket
26
+ # region and so forth.
27
+ #
28
+ # @param bucket[Aws::S3::Bucket] the AWS bucket resource object
29
+ # @param extra_attributes[Hash] any extra keyword arguments to pass to `S3Signer.new`
30
+ # @return [WT::S3Signer]
31
+ def self.for_s3_bucket(bucket, **extra_attributes)
32
+ kwargs = {}
33
+
34
+ kwargs[:bucket_endpoint_url] = bucket.url
35
+ kwargs[:bucket_host] = URI.parse(bucket.url).host
36
+ kwargs[:bucket_name] = bucket.name
37
+
38
+ client = Aws::S3::Client.new
39
+ resp = client.get_bucket_location(bucket: bucket.name)
40
+ aws_region = resp.data.location_constraint
41
+
42
+ # us-east-1 is a special AWS region (the oldest) and one
43
+ # of the specialties is that when you ask for the region
44
+ # of a bucket you get an empty string back instead of the
45
+ # actual name of the region. We need to compensate for that
46
+ # because if our region name is empty our signature will _not_
47
+ # be accepted by S3 (but only for buckets in the us-east-1 region!)
48
+ kwargs[:aws_region] = aws_region == "" ? "us-east-1" : aws_region
49
+
50
+ credentials = client.config.credentials
51
+ credentials = credentials.credentials if credentials.respond_to?(:credentials)
52
+ kwargs[:access_key_id] = credentials.access_key_id
53
+ kwargs[:secret_access_key] = credentials.secret_access_key
54
+ kwargs[:session_token] = credentials.session_token
55
+
56
+ new(**kwargs, **extra_attributes)
57
+ end
58
+
59
+ # Creates a new instance of WT::S3Signer
60
+ #
61
+ # @param now[Time] The timestamp to use for the signature (the `expires_in` is also relative to that time)
62
+ # @param expires_in[Integer] The number of seconds the URL will stay current from `now`
63
+ # @param aws_region[String] The name of the AWS region. Also needs to be set to "us-east-1" for the respective region.
64
+ # @param bucket_endpoint_url[String] The endpoint URL for the bucket (usually same as the bucket hostname as resolved by the SDK)
65
+ # @param bucket_host[String] The bucket endpoint hostname (usually derived from the bucket endpoint URL)
66
+ # @param bucket_name[String] The bucket name
67
+ # @param access_key_id[String] The IAM access key ID
68
+ # @param secret_access_key[String] The IAM secret access key
69
+ # @param session_token[String,nil] The IAM session token if STS sessions are used
70
+ def initialize(now: Time.now, expires_in:, aws_region:, bucket_endpoint_url:, bucket_host:, bucket_name:, access_key_id:, secret_access_key:, session_token:)
71
+ @region = aws_region
72
+ @service = "s3"
73
+
74
+ @expires_in = expires_in
75
+ @bucket_endpoint = bucket_endpoint_url
76
+ @bucket_host = bucket_host
77
+ @bucket_name = bucket_name
78
+ @now = now.utc
79
+ @secret_key = secret_access_key
80
+ @access_key = access_key_id
81
+ @session_token = session_token
82
+ end
83
+
84
+ # Creates a signed URL for the given S3 object key.
85
+ # The URL is temporary and the expiration time is based on the
86
+ # expires_in value on initialize
87
+ #
88
+ # @param object_key[String] The S3 key that needs a presigned url
89
+ #
90
+ # @raise [ArgumentError] Raises an ArgumentError if `object_key:`
91
+ # is empty.
92
+ #
93
+ # @return [String] The signed url
94
+ def presigned_get_url(object_key:)
95
+ # Variables that do not change during consecutive calls to the
96
+ # method are instance variables. This way they are not assigned
97
+ # every single time and are cached
98
+ if (object_key.nil? || object_key == "")
99
+ raise ArgumentError, "object_key: must not be empty"
100
+ end
101
+
102
+ @datestamp ||= @now.strftime("%Y%m%d")
103
+ @amz_date ||= @now.strftime("%Y%m%dT%H%M%SZ")
104
+
105
+ # ------ TASK 1: Create the canonical request
106
+ # -- Step 1: define the method
107
+ @method ||= "GET"
108
+
109
+ # -- Step 2: create canonical uri
110
+ # The canonical URI (the URI path) is the only thing
111
+ # that changes depending on the object key
112
+ canonical_uri = "/" + object_key # Might need URL escaping (!)
113
+
114
+ # -- Step 3: create the canonical headers
115
+ @canonical_headers ||= "host:" + @bucket_host + "\n"
116
+ @signed_headers ||= "host"
117
+
118
+ # -- Step 4: create the canonical query string
119
+ @algorithm ||= "AWS4-HMAC-SHA256"
120
+ @credential_scope ||= @datestamp + "/" + @region + "/" + @service + "/" + "aws4_request"
121
+
122
+ @canonical_querystring_template ||= begin
123
+ [
124
+ "X-Amz-Algorithm=#{@algorithm}",
125
+ "X-Amz-Credential=" + CGI.escape(@access_key + "/" + @credential_scope),
126
+ "X-Amz-Date=" + @amz_date,
127
+ "X-Amz-Expires=%d" % @expires_in,
128
+ # ------- When using STS we also need to add the security token
129
+ ("X-Amz-Security-Token=" + CGI.escape(@session_token) if @session_token),
130
+ "X-Amz-SignedHeaders=" + @signed_headers,
131
+ ].compact.join('&')
132
+ end
133
+
134
+ # -- Step 5: create payload
135
+ @payload ||= "UNSIGNED-PAYLOAD"
136
+
137
+ # -- Step 6: combine elements to create the canonical request
138
+ canonical_request = [
139
+ @method,
140
+ canonical_uri,
141
+ @canonical_querystring_template,
142
+ @canonical_headers,
143
+ @signed_headers,
144
+ @payload
145
+ ].join("\n")
146
+
147
+ # ------ TASK 2: Create a String to sign
148
+ string_to_sign = [
149
+ @algorithm,
150
+ @amz_date,
151
+ @credential_scope,
152
+ Digest::SHA256.hexdigest(canonical_request)
153
+ ].join("\n")
154
+
155
+ # ------ TASK 3: Calculate the signature
156
+ @signing_key ||= derive_signing_key(@secret_key, @datestamp, @region, @service)
157
+ signature = OpenSSL::HMAC.hexdigest("SHA256", @signing_key, string_to_sign)
158
+
159
+ # ------ TASK 4: Add signing information to the request
160
+ qs_with_signature = @canonical_querystring_template + "&X-Amz-Signature=" + signature
161
+
162
+ @bucket_endpoint + canonical_uri + "?" + qs_with_signature
163
+ end
164
+
165
+ private
166
+
167
+ def create_bucket(bucket_name)
168
+ Aws::S3::Bucket.new(bucket_name)
169
+ end
170
+
171
+ def derive_signing_key(key, datestamp, region, service)
172
+ prefixed_key = "AWS4" + key
173
+ k_date = hmac_bytes(prefixed_key, datestamp)
174
+ k_region = hmac_bytes(k_date, region)
175
+ k_service = hmac_bytes(k_region, service)
176
+ hmac_bytes(k_service, "aws4_request")
177
+ end
178
+
179
+ def hmac_bytes(key, data)
180
+ OpenSSL::HMAC.digest("SHA256", key, data)
181
+ end
182
+ end
183
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wt_s3_signer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Luca Suriano
8
+ - Julik Tarkhanov
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2019-12-16 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: aws-sdk-s3
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '1'
28
+ - !ruby/object:Gem::Dependency
29
+ name: yard
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: 0.9.24
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: 0.9.24
42
+ - !ruby/object:Gem::Dependency
43
+ name: rake
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: 13.0.1
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: 13.0.1
56
+ - !ruby/object:Gem::Dependency
57
+ name: rspec
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '3.9'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '3.9'
70
+ - !ruby/object:Gem::Dependency
71
+ name: rspec-benchmark
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: 0.5.1
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: 0.5.1
84
+ - !ruby/object:Gem::Dependency
85
+ name: rubocop
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ description: A Ruby Gem that optimize the signing of S3 keys. The gem is especially
99
+ useful when dealing with a large amount of S3 object keys
100
+ email:
101
+ - luca.suriano@wetransfer.com
102
+ - me@julik.nl
103
+ executables: []
104
+ extensions: []
105
+ extra_rdoc_files: []
106
+ files:
107
+ - lib/wt_s3_signer.rb
108
+ homepage: https://github.com/WeTransfer/wt_s3_signer
109
+ licenses:
110
+ - MIT (Hippocratic)
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 3.0.3
128
+ signing_key:
129
+ specification_version: 4
130
+ summary: A library for signing S3 key faster
131
+ test_files: []