Class: Aws::Sigv4::Signer

Inherits:
Object
  • Object
show all
Defined in:
lib/aws-sigv4/signer.rb

Overview

Utility class for creating AWS signature version 4 signature. This class provides two methods for generating signatures:

  • #sign_request - Computes a signature of the given request, returning the hash of headers that should be applied to the request.

  • #presign_url - Computes a presigned request with an expiration. By default, the body of this request is not signed and the request expires in 15 minutes.

## Configuration

To use the signer, you need to specify the service, region, and credentials. The service name is normally the endpoint prefix to an AWS service. For example:

ec2.us-west-1.amazonaws.com => ec2

The region is normally the second portion of the endpoint, following the service name.

ec2.us-west-1.amazonaws.com => us-west-1

It is important to have the correct service and region name, or the signature will be invalid.

## Credentials

The signer requires credentials. You can configure the signer with static credentials:

signer = Aws::Sigv4::Signer.new(
  service: 's3',
  region: 'us-east-1',
  # static credentials
  access_key_id: 'akid',
  secret_access_key: 'secret'
)

You can also provide refreshing credentials via the ‘:credentials_provider`. If you are using the AWS SDK for Ruby, you can use any of the credential classes:

signer = Aws::Sigv4::Signer.new(
  service: 's3',
  region: 'us-east-1',
  credentials_provider: Aws::InstanceProfileCredentials.new
)

Other AWS SDK for Ruby classes that can be provided via ‘:credentials_provider`:

  • ‘Aws::Credentials`

  • ‘Aws::SharedCredentials`

  • ‘Aws::InstanceProfileCredentials`

  • ‘Aws::AssumeRoleCredentials`

  • ‘Aws::ECSCredentials`

A credential provider is any object that responds to ‘#credentials` returning another object that responds to `#access_key_id`, `#secret_access_key`, and `#session_token`.

Constant Summary collapse

@@use_crt =
begin
  require 'aws-crt'
  true
rescue LoadError
  false
end

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(service: , region: , access_key_id: , secret_access_key: , session_token: nil, **options) ⇒ Signer #initialize(service: , region: , credentials: , **options) ⇒ Signer #initialize(service: , region: , credentials_provider: , **options) ⇒ Signer

Returns a new instance of Signer.

Options Hash (options):

  • :unsigned_headers (Array<String>) — default: []

    A list of headers that should not be signed. This is useful when a proxy modifies headers, such as ‘User-Agent’, invalidating a signature.

  • :uri_escape_path (Boolean) — default: true

    When ‘true`, the request URI path is uri-escaped as part of computing the canonical request string. This is required for every service, except Amazon S3, as of late 2016.

  • :apply_checksum_header (Boolean) — default: true

    When ‘true`, the computed content checksum is returned in the hash of signature headers. This is required for AWS Glacier, and optional for every other AWS service as of late 2016.

  • :signing_algorithm (Symbol) — default: :sigv4

    The algorithm to use for signing.

  • :omit_session_token (Boolean) — default: false

    (Supported only when ‘aws-crt` is available) If `true`, then security token is added to the final signing result, but is treated as “unsigned” and does not contribute to the authorization signature.

  • :normalize_path (Boolean) — default: true

    When ‘true`, the uri paths will be normalized when building the canonical request.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/aws-sigv4/signer.rb', line 144

def initialize(options = {})
  @service = extract_service(options)
  @region = extract_region(options)
  @credentials_provider = extract_credentials_provider(options)
  @unsigned_headers = Set.new((options.fetch(:unsigned_headers, [])).map(&:downcase))
  @unsigned_headers << 'authorization'
  @unsigned_headers << 'x-amzn-trace-id'
  @unsigned_headers << 'expect'
  @uri_escape_path = options.fetch(:uri_escape_path, true)
  @apply_checksum_header = options.fetch(:apply_checksum_header, true)
  @signing_algorithm = options.fetch(:signing_algorithm, :sigv4)
  @normalize_path = options.fetch(:normalize_path, true)
  @omit_session_token = options.fetch(:omit_session_token, false)

  if @signing_algorithm == 'sigv4-s3express'.to_sym &&
     Signer.use_crt? && Aws::Crt::GEM_VERSION <= '0.1.9'
    raise ArgumentError,
          'This version of aws-crt does not support S3 Express. Please
           update this gem to at least version 0.2.0.'
  end
end

Instance Attribute Details

#apply_checksum_headerBoolean (readonly)



189
190
191
# File 'lib/aws-sigv4/signer.rb', line 189

def apply_checksum_header
  @apply_checksum_header
end

#credentials_provider#credentials (readonly)



181
182
183
# File 'lib/aws-sigv4/signer.rb', line 181

def credentials_provider
  @credentials_provider
end

#regionString (readonly)



170
171
172
# File 'lib/aws-sigv4/signer.rb', line 170

def region
  @region
end

#serviceString (readonly)



167
168
169
# File 'lib/aws-sigv4/signer.rb', line 167

def service
  @service
end

#unsigned_headersSet<String> (readonly)



185
186
187
# File 'lib/aws-sigv4/signer.rb', line 185

def unsigned_headers
  @unsigned_headers
end

Class Method Details

.normalize_path(uri) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



944
945
946
947
948
949
950
951
952
953
954
# File 'lib/aws-sigv4/signer.rb', line 944

def normalize_path(uri)
  normalized_path = Pathname.new(uri.path).cleanpath.to_s
  # Pathname is probably not correct to use. Empty paths will
  # resolve to "." and should be disregarded
  normalized_path = '' if normalized_path == '.'
  # Ensure trailing slashes are correctly preserved
  if uri.path.end_with?('/') && !normalized_path.end_with?('/')
    normalized_path << '/'
  end
  uri.path = normalized_path
end

.uri_escape(string) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



935
936
937
938
939
940
941
# File 'lib/aws-sigv4/signer.rb', line 935

def uri_escape(string)
  if string.nil?
    nil
  else
    CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~')
  end
end

.uri_escape_path(path) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



930
931
932
# File 'lib/aws-sigv4/signer.rb', line 930

def uri_escape_path(path)
  path.gsub(/[^\/]+/) { |part| uri_escape(part) }
end

.use_crt?Boolean



925
926
927
# File 'lib/aws-sigv4/signer.rb', line 925

def use_crt?
  @@use_crt
end

Instance Method Details

#presign_url(options) ⇒ HTTPS::URI, HTTP::URI

Signs a URL with query authentication. Using query parameters to authenticate requests is useful when you want to express a request entirely in a URL. This method is also referred as presigning a URL.

See [Authenticating Requests: Using Query Parameters (AWS Signature Version 4)](docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html) for more information.

To generate a presigned URL, you must provide a HTTP URI and the http method.

url = signer.presign_url(
  http_method: 'GET',
  url: 'https://my-bucket.s3-us-east-1.amazonaws.com/key',
  expires_in: 60
)

By default, signatures are valid for 15 minutes. You can specify the number of seconds for the URL to expire in.

url = signer.presign_url(
  http_method: 'GET',
  url: 'https://my-bucket.s3-us-east-1.amazonaws.com/key',
  expires_in: 3600 # one hour
)

You can provide a hash of headers that you plan to send with the request. Every ‘X-Amz-*’ header you plan to send with the request must be provided, or the signature is invalid. Other headers are optional, but should be provided for security reasons.

url = signer.presign_url(
  http_method: 'PUT',
  url: 'https://my-bucket.s3-us-east-1.amazonaws.com/key',
  headers: {
    'X-Amz-Meta-Custom' => 'metadata'
  }
)

Options Hash (options):

  • :http_method (required, String)

    The HTTP request method, e.g. ‘GET’, ‘HEAD’, ‘PUT’, ‘POST’, ‘PATCH’, or ‘DELETE’.

  • :url (required, String, HTTPS::URI, HTTP::URI)

    The URI to sign.

  • :headers (Hash) — default: {}

    Headers that should be signed and sent along with the request. All x-amz-* headers must be present during signing. Other headers are optional.

  • :expires_in (Integer<Seconds>) — default: 900

    How long the presigned URL should be valid for. Defaults to 15 minutes (900 seconds).

  • :body (optional, String, IO)

    If the ‘:body` is set, then a SHA256 hexdigest will be computed of the body. If `:body_digest` is set, this option is ignored. If neither are set, then the `:body_digest` will be computed of the empty string.

  • :body_digest (optional, String)

    The SHA256 hexdigest of the request body. If you wish to send the presigned request without signing the body, you can pass ‘UNSIGNED-PAYLOAD’ as the ‘:body_digest` in place of passing `:body`.

  • :time (Time) — default: Time.now

    Time of the signature. You should only set this value for testing.



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# File 'lib/aws-sigv4/signer.rb', line 433

def presign_url(options)

  return crt_presign_url(options) if Signer.use_crt?

  creds, expiration = fetch_credentials

  http_method = extract_http_method(options)
  url = extract_url(options)
  Signer.normalize_path(url) if @normalize_path

  headers = downcase_headers(options[:headers])
  headers['host'] ||= host(url)

  datetime = headers['x-amz-date']
  datetime ||= (options[:time] || Time.now).utc.strftime("%Y%m%dT%H%M%SZ")
  date = datetime[0,8]

  content_sha256 = headers['x-amz-content-sha256']
  content_sha256 ||= options[:body_digest]
  content_sha256 ||= sha256_hexdigest(options[:body] || '')

  algorithm = sts_algorithm

  params = {}
  params['X-Amz-Algorithm'] = algorithm
  params['X-Amz-Credential'] = credential(creds, date)
  params['X-Amz-Date'] = datetime
  params['X-Amz-Expires'] = presigned_url_expiration(options, expiration, Time.strptime(datetime, "%Y%m%dT%H%M%S%Z")).to_s
  if creds.session_token
    if @signing_algorithm == 'sigv4-s3express'.to_sym
      params['X-Amz-S3session-Token'] = creds.session_token
    else
      params['X-Amz-Security-Token'] = creds.session_token
    end
  end
  params['X-Amz-SignedHeaders'] = signed_headers(headers)

  if @signing_algorithm == :sigv4a && @region
    params['X-Amz-Region-Set'] = @region
  end

  params = params.map do |key, value|
    "#{uri_escape(key)}=#{uri_escape(value)}"
  end.join('&')

  if url.query
    url.query += '&' + params
  else
    url.query = params
  end

  creq = canonical_request(http_method, url, headers, content_sha256)
  sts = string_to_sign(datetime, creq, algorithm)
  signature =
    if @signing_algorithm == :sigv4a
      asymmetric_signature(creds, sts)
    else
      signature(creds.secret_access_key, date, sts)
    end
  url.query += '&X-Amz-Signature=' + signature
  url
end

#sign_event(prior_signature, payload, encoder) ⇒ Object

Signs a event and returns signature headers and prior signature used for next event signing.

Headers of a sigv4 signed event message only contains 2 headers

* ':chunk-signature'
  * computed signature of the event, binary string, 'bytes' type
* ':date'
  * millisecond since epoch, 'timestamp' type

Payload of the sigv4 signed event message contains eventstream encoded message which is serialized based on input and protocol

To sign events

headers_0, signature_0 = signer.sign_event(
  prior_signature, # hex-encoded string
  payload_0, # binary string (eventstream encoded event 0)
  encoder, # Aws::EventStreamEncoder
)

headers_1, signature_1 = signer.sign_event(
  signature_0,
  payload_1, # binary string (eventstream encoded event 1)
  encoder
)

The initial prior_signature should be using the signature computed at initial request

Note:

Since ':chunk-signature' header value has bytes type, the signature value provided
needs to be a binary string instead of a hex-encoded string (like original signature
V4 algorithm). Thus, when returning signature value used for next event siging, the
signature value (a binary string) used at ':chunk-signature' needs to converted to
hex-encoded string using #unpack


346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/aws-sigv4/signer.rb', line 346

def sign_event(prior_signature, payload, encoder)
  # Note: CRT does not currently provide event stream signing, so we always use the ruby implementation.
  creds, _ = fetch_credentials
  time = Time.now
  headers = {}

  datetime = time.utc.strftime("%Y%m%dT%H%M%SZ")
  date = datetime[0,8]
  headers[':date'] = Aws::EventStream::HeaderValue.new(value: time.to_i * 1000, type: 'timestamp')

  sts = event_string_to_sign(datetime, headers, payload, prior_signature, encoder)
  sig = event_signature(creds.secret_access_key, date, sts)

  headers[':chunk-signature'] = Aws::EventStream::HeaderValue.new(value: sig, type: 'bytes')

  # Returning signed headers and signature value in hex-encoded string
  [headers, sig.unpack('H*').first]
end

#sign_request(request) ⇒ Signature

Computes a version 4 signature signature. Returns the resultant signature as a hash of headers to apply to your HTTP request. The given request is not modified.

signature = signer.sign_request(
  http_method: 'PUT',
  url: 'https://domain.com',
  headers: {
    'Abc' => 'xyz',
  },
  body: 'body' # String or IO object
)

# Apply the following hash of headers to your HTTP request
signature.headers['host']
signature.headers['x-amz-date']
signature.headers['x-amz-security-token']
signature.headers['x-amz-content-sha256']
signature.headers['authorization']

In addition to computing the signature headers, the canonicalized request, string to sign and content sha256 checksum are also available. These values are useful for debugging signature errors returned by AWS.

signature.canonical_request #=> "..."
signature.string_to_sign #=> "..."
signature.content_sha256 #=> "..."

Options Hash (request):

  • :http_method (required, String)

    One of ‘GET’, ‘HEAD’, ‘PUT’, ‘POST’, ‘PATCH’, or ‘DELETE’

  • :url (required, String, URI::HTTPS, URI::HTTP)

    The request URI. Must be a valid HTTP or HTTPS URI.

  • :headers (optional, Hash) — default: {}

    A hash of headers to sign. If the ‘X-Amz-Content-Sha256’ header is set, the ‘:body` is optional and will not be read.

  • :body (optional, String, IO) — default: 'X-Amz-Content-Sha256'ody. A sha256 checksum is computed of the body unless the 'X-Amz-Content-Sha256' header is set.

    ”) The HTTP request body. A sha256 checksum is computed of the body unless the ‘X-Amz-Content-Sha256’ header is set.



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/aws-sigv4/signer.rb', line 238

def sign_request(request)

  return crt_sign_request(request) if Signer.use_crt?

  creds, _ = fetch_credentials

  http_method = extract_http_method(request)
  url = extract_url(request)
  Signer.normalize_path(url) if @normalize_path
  headers = downcase_headers(request[:headers])

  datetime = headers['x-amz-date']
  datetime ||= Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
  date = datetime[0,8]

  content_sha256 = headers['x-amz-content-sha256']
  content_sha256 ||= sha256_hexdigest(request[:body] || '')

  sigv4_headers = {}
  sigv4_headers['host'] = headers['host'] || host(url)
  sigv4_headers['x-amz-date'] = datetime
  if creds.session_token && !@omit_session_token
    if @signing_algorithm == 'sigv4-s3express'.to_sym
      sigv4_headers['x-amz-s3session-token'] = creds.session_token
    else
      sigv4_headers['x-amz-security-token'] = creds.session_token
    end
  end

  sigv4_headers['x-amz-content-sha256'] ||= content_sha256 if @apply_checksum_header

  if @signing_algorithm == :sigv4a && @region && !@region.empty?
    sigv4_headers['x-amz-region-set'] = @region
  end
  headers = headers.merge(sigv4_headers) # merge so we do not modify given headers hash

  algorithm = sts_algorithm

  # compute signature parts
  creq = canonical_request(http_method, url, headers, content_sha256)
  sts = string_to_sign(datetime, creq, algorithm)

  sig =
    if @signing_algorithm == :sigv4a
      asymmetric_signature(creds, sts)
    else
      signature(creds.secret_access_key, date, sts)
    end

  algorithm = sts_algorithm

  # apply signature
  sigv4_headers['authorization'] = [
    "#{algorithm} Credential=#{credential(creds, date)}",
    "SignedHeaders=#{signed_headers(headers)}",
    "Signature=#{sig}",
  ].join(', ')

  # skip signing the session token, but include it in the headers
  if creds.session_token && @omit_session_token
    sigv4_headers['x-amz-security-token'] = creds.session_token
  end

  # Returning the signature components.
  Signature.new(
    headers: sigv4_headers,
    string_to_sign: sts,
    canonical_request: creq,
    content_sha256: content_sha256,
    signature: sig
  )
end