Skip to content

Commit 6ec4c3a

Browse files
authored
Implement Sigv4a in pure Ruby (#3071)
1 parent 34308c3 commit 6ec4c3a

File tree

436 files changed

+2648
-26
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

436 files changed

+2648
-26
lines changed

Gemfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ unless defined?(JRUBY_VERSION)
2222
gem 'ox'
2323
end
2424

25+
if defined?(JRUBY_VERSION)
26+
# get the latest jruby-openssl to support sigv4a
27+
# see: https://github.com/jruby/jruby-openssl/issues/30
28+
gem 'jruby-openssl'
29+
end
30+
2531
group :test do
2632
gem 'addressable'
2733
gem 'cucumber'

gems/aws-sigv4/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Unreleased Changes
22
------------------
33

4+
* Feature - Support `sigv4a` signing algorithm without `aws-crt`.
5+
46
1.8.0 (2023-11-28)
57
------------------
68

gems/aws-sigv4/lib/aws-sigv4.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative 'aws-sigv4/asymmetric_credentials'
34
require_relative 'aws-sigv4/credentials'
45
require_relative 'aws-sigv4/errors'
56
require_relative 'aws-sigv4/signature'
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
module Aws
4+
module Sigv4
5+
# To make it easier to support mixed mode, we have created an asymmetric
6+
# key derivation mechanism. This module derives
7+
# asymmetric keys from the current secret for use with
8+
# Asymmetric signatures.
9+
# @api private
10+
module AsymmetricCredentials
11+
12+
N_MINUS_2 = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 - 2
13+
14+
# @param [String] :access_key_id
15+
# @param [String] :secret_access_key
16+
# @return [OpenSSL::PKey::EC, Hash]
17+
def self.derive_asymmetric_key(access_key_id, secret_access_key)
18+
check_openssl_support!
19+
label = 'AWS4-ECDSA-P256-SHA256'
20+
bit_len = 256
21+
counter = 0x1
22+
input_key = "AWS4A#{secret_access_key}"
23+
d = 0 # d will end up being the private key
24+
while true do
25+
26+
kdf_context = access_key_id.unpack('C*') + [counter].pack('C').unpack('C') #1 byte for counter
27+
input = label.unpack('C*') + [0x00] + kdf_context + [bit_len].pack('L>').unpack('CCCC') # 4 bytes (change endianess)
28+
k0 = OpenSSL::HMAC.digest("SHA256", input_key, ([0, 0, 0, 0x01] + input).pack('C*'))
29+
c = be_bytes_to_num( k0.unpack('C*') )
30+
if c <= N_MINUS_2
31+
d = c + 1
32+
break
33+
elsif counter > 0xFF
34+
raise 'Counter exceeded 1 byte - unable to get asym creds'
35+
else
36+
counter += 1
37+
end
38+
end
39+
40+
# compute the public key
41+
group = OpenSSL::PKey::EC::Group.new('prime256v1')
42+
public_key = group.generator.mul(d)
43+
44+
ec = generate_ec(public_key, d)
45+
46+
# pk_x and pk_y are not needed for signature, but useful in verification/testing
47+
pk_b = public_key.to_octet_string(:uncompressed).unpack('C*') # 0x04 byte followed by 2 32-byte integers
48+
pk_x = be_bytes_to_num(pk_b[1,32])
49+
pk_y = be_bytes_to_num(pk_b[33,32])
50+
[ec, {ec: ec, public_key: public_key, pk_x: pk_x, pk_y: pk_y, d: d}]
51+
end
52+
53+
private
54+
55+
# @return [Number] The value of the bytes interpreted as a big-endian
56+
# unsigned integer.
57+
def self.be_bytes_to_num(bytes)
58+
x = 0
59+
bytes.each { |b| x = (x*256) + b }
60+
x
61+
end
62+
63+
# Prior to openssl3 we could directly set public and private key on EC
64+
# However, openssl3 deprecated those methods and we must now construct
65+
# a der with the keys and load the EC from it.
66+
def self.generate_ec(public_key, d)
67+
# format reversed from: OpenSSL::ASN1.decode_all(OpenSSL::PKey::EC.new.to_der)
68+
asn1 = OpenSSL::ASN1::Sequence([
69+
OpenSSL::ASN1::Integer(OpenSSL::BN.new(1)),
70+
OpenSSL::ASN1::OctetString([d.to_s(16)].pack('H*')),
71+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId("prime256v1")], 0, :CONTEXT_SPECIFIC),
72+
OpenSSL::ASN1::ASN1Data.new(
73+
[OpenSSL::ASN1::BitString(public_key.to_octet_string(:uncompressed))],
74+
1, :CONTEXT_SPECIFIC
75+
)
76+
])
77+
OpenSSL::PKey::EC.new(asn1.to_der)
78+
end
79+
80+
def self.check_openssl_support!
81+
return true unless defined?(JRUBY_VERSION)
82+
83+
# See: https://github.com/jruby/jruby-openssl/issues/306
84+
# JRuby-openssl < 0.15 does not support OpenSSL::PKey::EC::Point#mul
85+
return true if OpenSSL::PKey::EC::Point.instance_methods.include?(:mul)
86+
87+
raise 'Sigv4a Asymmetric Credential derivation requires jruby-openssl >= 0.15'
88+
end
89+
end
90+
end
91+
end

gems/aws-sigv4/lib/aws-sigv4/signature.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ def initialize(options)
3232
# @return [String] For debugging purposes.
3333
attr_accessor :content_sha256
3434

35+
# @return [String] For debugging purposes.
36+
attr_accessor :signature
37+
3538
# @return [Hash] Internal data for debugging purposes.
3639
attr_accessor :extra
3740
end

gems/aws-sigv4/lib/aws-sigv4/signer.rb

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@ class Signer
130130
# every other AWS service as of late 2016.
131131
#
132132
# @option options [Symbol] :signing_algorithm (:sigv4) The
133-
# algorithm to use for signing. :sigv4a is only supported when
134-
# `aws-crt` is available.
133+
# algorithm to use for signing.
135134
#
136135
# @option options [Boolean] :omit_session_token (false)
137136
# (Supported only when `aws-crt` is available) If `true`,
@@ -155,12 +154,6 @@ def initialize(options = {})
155154
@normalize_path = options.fetch(:normalize_path, true)
156155
@omit_session_token = options.fetch(:omit_session_token, false)
157156

158-
if @signing_algorithm == :sigv4a && !Signer.use_crt?
159-
raise ArgumentError, 'You are attempting to sign a' \
160-
' request with sigv4a which requires the `aws-crt` gem.'\
161-
' Please install the gem or add it to your gemfile.'
162-
end
163-
164157
if @signing_algorithm == 'sigv4-s3express'.to_sym &&
165158
Signer.use_crt? && Aws::Crt::GEM_VERSION <= '0.1.9'
166159
raise ArgumentError,
@@ -249,6 +242,7 @@ def sign_request(request)
249242

250243
http_method = extract_http_method(request)
251244
url = extract_url(request)
245+
Signer.normalize_path(url) if @normalize_path
252246
headers = downcase_headers(request[:headers])
253247

254248
datetime = headers['x-amz-date']
@@ -261,7 +255,7 @@ def sign_request(request)
261255
sigv4_headers = {}
262256
sigv4_headers['host'] = headers['host'] || host(url)
263257
sigv4_headers['x-amz-date'] = datetime
264-
if creds.session_token
258+
if creds.session_token && !@omit_session_token
265259
if @signing_algorithm == 'sigv4-s3express'.to_sym
266260
sigv4_headers['x-amz-s3session-token'] = creds.session_token
267261
else
@@ -271,26 +265,45 @@ def sign_request(request)
271265

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

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

273+
algorithm = sts_algorithm
274+
276275
# compute signature parts
277276
creq = canonical_request(http_method, url, headers, content_sha256)
278-
sts = string_to_sign(datetime, creq)
279-
sig = signature(creds.secret_access_key, date, sts)
277+
sts = string_to_sign(datetime, creq, algorithm)
278+
279+
sig =
280+
if @signing_algorithm == :sigv4a
281+
asymmetric_signature(creds, sts)
282+
else
283+
signature(creds.secret_access_key, date, sts)
284+
end
285+
286+
algorithm = sts_algorithm
280287

281288
# apply signature
282289
sigv4_headers['authorization'] = [
283-
"AWS4-HMAC-SHA256 Credential=#{credential(creds, date)}",
290+
"#{algorithm} Credential=#{credential(creds, date)}",
284291
"SignedHeaders=#{signed_headers(headers)}",
285292
"Signature=#{sig}",
286293
].join(', ')
287294

295+
# skip signing the session token, but include it in the headers
296+
if creds.session_token && @omit_session_token
297+
sigv4_headers['x-amz-security-token'] = creds.session_token
298+
end
299+
288300
# Returning the signature components.
289301
Signature.new(
290302
headers: sigv4_headers,
291303
string_to_sign: sts,
292304
canonical_request: creq,
293-
content_sha256: content_sha256
305+
content_sha256: content_sha256,
306+
signature: sig
294307
)
295308
end
296309

@@ -424,6 +437,7 @@ def presign_url(options)
424437

425438
http_method = extract_http_method(options)
426439
url = extract_url(options)
440+
Signer.normalize_path(url) if @normalize_path
427441

428442
headers = downcase_headers(options[:headers])
429443
headers['host'] ||= host(url)
@@ -436,8 +450,10 @@ def presign_url(options)
436450
content_sha256 ||= options[:body_digest]
437451
content_sha256 ||= sha256_hexdigest(options[:body] || '')
438452

453+
algorithm = sts_algorithm
454+
439455
params = {}
440-
params['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256'
456+
params['X-Amz-Algorithm'] = algorithm
441457
params['X-Amz-Credential'] = credential(creds, date)
442458
params['X-Amz-Date'] = datetime
443459
params['X-Amz-Expires'] = presigned_url_expiration(options, expiration, Time.strptime(datetime, "%Y%m%dT%H%M%S%Z")).to_s
@@ -450,6 +466,10 @@ def presign_url(options)
450466
end
451467
params['X-Amz-SignedHeaders'] = signed_headers(headers)
452468

469+
if @signing_algorithm == :sigv4a && @region
470+
params['X-Amz-Region-Set'] = @region
471+
end
472+
453473
params = params.map do |key, value|
454474
"#{uri_escape(key)}=#{uri_escape(value)}"
455475
end.join('&')
@@ -461,13 +481,23 @@ def presign_url(options)
461481
end
462482

463483
creq = canonical_request(http_method, url, headers, content_sha256)
464-
sts = string_to_sign(datetime, creq)
465-
url.query += '&X-Amz-Signature=' + signature(creds.secret_access_key, date, sts)
484+
sts = string_to_sign(datetime, creq, algorithm)
485+
signature =
486+
if @signing_algorithm == :sigv4a
487+
asymmetric_signature(creds, sts)
488+
else
489+
signature(creds.secret_access_key, date, sts)
490+
end
491+
url.query += '&X-Amz-Signature=' + signature
466492
url
467493
end
468494

469495
private
470496

497+
def sts_algorithm
498+
@signing_algorithm == :sigv4a ? 'AWS4-ECDSA-P256-SHA256' : 'AWS4-HMAC-SHA256'
499+
end
500+
471501
def canonical_request(http_method, url, headers, content_sha256)
472502
[
473503
http_method,
@@ -479,9 +509,9 @@ def canonical_request(http_method, url, headers, content_sha256)
479509
].join("\n")
480510
end
481511

482-
def string_to_sign(datetime, canonical_request)
512+
def string_to_sign(datetime, canonical_request, algorithm)
483513
[
484-
'AWS4-HMAC-SHA256',
514+
algorithm,
485515
datetime,
486516
credential_scope(datetime[0,8]),
487517
sha256_hexdigest(canonical_request),
@@ -514,10 +544,10 @@ def event_string_to_sign(datetime, headers, payload, prior_signature, encoder)
514544
def credential_scope(date)
515545
[
516546
date,
517-
@region,
547+
(@region unless @signing_algorithm == :sigv4a),
518548
@service,
519-
'aws4_request',
520-
].join('/')
549+
'aws4_request'
550+
].compact.join('/')
521551
end
522552

523553
def credential(credentials, date)
@@ -532,6 +562,16 @@ def signature(secret_access_key, date, string_to_sign)
532562
hexhmac(k_credentials, string_to_sign)
533563
end
534564

565+
def asymmetric_signature(creds, string_to_sign)
566+
ec, _ = Aws::Sigv4::AsymmetricCredentials.derive_asymmetric_key(
567+
creds.access_key_id, creds.secret_access_key
568+
)
569+
sts_digest = OpenSSL::Digest::SHA256.digest(string_to_sign)
570+
s = ec.dsa_sign_asn1(sts_digest)
571+
572+
Digest.hexencode(s)
573+
end
574+
535575
# Comparing to original signature v4 algorithm,
536576
# returned signature is a binary string instread of
537577
# hex-encoded string. (Since ':chunk-signature' requires
@@ -899,6 +939,18 @@ def uri_escape(string)
899939
end
900940
end
901941

942+
# @api private
943+
def normalize_path(uri)
944+
normalized_path = Pathname.new(uri.path).cleanpath.to_s
945+
# Pathname is probably not correct to use. Empty paths will
946+
# resolve to "." and should be disregarded
947+
normalized_path = '' if normalized_path == '.'
948+
# Ensure trailing slashes are correctly preserved
949+
if uri.path.end_with?('/') && !normalized_path.end_with?('/')
950+
normalized_path << '/'
951+
end
952+
uri.path = normalized_path
953+
end
902954
end
903955
end
904956
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'spec_helper'
4+
5+
module Aws
6+
module Sigv4
7+
describe AsymmetricCredentials do
8+
9+
# values for d,pk_x and pk_y are taken from get-vanilla sigv4a reference test
10+
let(:access_key_id) { 'AKIDEXAMPLE' }
11+
let(:secret_access_key) { 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }
12+
let(:ec) do
13+
subject.derive_asymmetric_key(access_key_id, secret_access_key)[0]
14+
end
15+
16+
let(:extra) do
17+
subject.derive_asymmetric_key(access_key_id, secret_access_key)[1]
18+
end
19+
20+
describe 'derive_asymmetric_key' do
21+
it 'returns an EC PKey' do
22+
expect(ec).to be_a(OpenSSL::PKey::EC)
23+
end
24+
25+
it 'computes the private key' do
26+
expect(extra[:d]).to be_a(Integer)
27+
expect(extra[:d]).to eq 57437631014447175651096573782723065210935272504912550018654791361221980923292
28+
end
29+
30+
it 'computes the public key' do
31+
expect(extra[:public_key]).to be_a(OpenSSL::PKey::EC::Point)
32+
end
33+
34+
it 'computes the pk_x and pk_y' do
35+
expect(extra[:pk_x]).to be_a(Integer)
36+
expect(extra[:pk_x]).to eq 82493312425604201858614910479538123276547530192671928569404457423490168469169
37+
38+
expect(extra[:pk_y]).to be_a(Integer)
39+
expect(extra[:pk_y]).to eq 60777455846638291266199385583357715250110920888403467466325436560561456866584
40+
end
41+
end
42+
end
43+
end
44+
end

0 commit comments

Comments
 (0)