right_support 2.8.1 → 2.8.2

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.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.8.1
1
+ 2.8.2
@@ -20,72 +20,181 @@
20
20
  # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
21
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
 
23
+ require 'digest/md5'
23
24
  require 'digest/sha1'
25
+ require 'digest/sha2'
26
+ require 'openssl'
24
27
 
25
28
  module RightSupport::Crypto
29
+ # An easy way to compute digital signatures of data contained in a Ruby hash. To work with
30
+ # signed hashes, you must first obtain an asymmetric key pair (any subclass of OpenSSL::PKey);
31
+ # you can generate it from scratch or load it from a file on disk.
32
+ #
33
+ # Signature computation is influenced by four factors:
34
+ # - The digital signature algorithm and key length
35
+ # - The encoding used to serialize the hash contents to a byte stream
36
+ # - The hash algorithm used to compute a message digest of the byte stream
37
+ # - The OpenSSL API level used (EVP or raw crypto API)
38
+ #
39
+ # You are responsible for providing the PKey object, which determines the signature algorithm
40
+ # and key length. This occasionally constrains your choice of hash algorithm; for instance,
41
+ # a 512-bit RSA key would not be sufficiently long to create signatures of a SHA3-512 hash
42
+ # due to the mathematical underpinnings of the RSA cipher. In practice this is not an issue,
43
+ # because you should be using strong RSA keys (2048 bit or higher) for security reasons,
44
+ # and even the strongest hash algorithms do not exceed 512-bit output.
45
+ #
46
+ # SignedHash provides reasonable defaults for the other three factors:
47
+ # - JSON for message encoding (Yajl gem, JSON gem, Oj gem or built-in Ruby 1.9 JSON)
48
+ # - SHA1 for message digest
49
+ # - raw crypto API (for compatibility with older RightSupport versions)
50
+ #
51
+ # If you are adopting SignedHash for a new use case, it's best to use the default
52
+ # encoding and message digest, but specify :envelope=>true to use the OpenSSL EVP
53
+ # API! Using an envelope provides better protection against various cryptographic
54
+ # attacks and ensures that the sign and verify operations can't be used.
55
+ #
56
+ # SignedHash defaults to raw-crypto signatures for compatibility reasons, but
57
+ # with RightSupport v3 the raw-crypto will be deprecated and EVP will be used by default.
58
+ #
59
+ # @see OpenSSL::PKey
60
+ # @see Digest
26
61
  class SignedHash
62
+ # The default encoding to use when dumping the hash to binary form. Defaults to any available
63
+ # commonly-known JSON library, in the following order of preference:
64
+ # - Yajl (ruby-yajl gem)
65
+ # - JSON (json gem, or built-in JSON parser for ruby >= 1.9)
66
+ # - Oj (oj gem)
67
+ # - nil (if no JSON library is present)
68
+ # @!parse DefaultEncoding = nil
27
69
 
28
70
  if require_succeeds?('yajl')
29
71
  DefaultEncoding = ::Yajl
30
- elsif require_succeeds?('oj')
31
- DefaultEncoding = ::Oj
32
72
  elsif require_succeeds?('json')
33
73
  DefaultEncoding = ::JSON
74
+ elsif require_succeeds?('oj')
75
+ DefaultEncoding = ::Oj
34
76
  else
35
77
  DefaultEncoding = nil
36
78
  end unless defined?(DefaultEncoding)
37
79
 
38
80
  DEFAULT_OPTIONS = {
39
81
  :digest => Digest::SHA1,
40
- :encoding => DefaultEncoding
82
+ :envelope => false,
83
+ :encoding => DefaultEncoding,
84
+ }
85
+
86
+ # Mapping of Ruby built-in hash algorithms to their OpenSSL counterparts
87
+ DIGEST_MAP = {
88
+ Digest::MD5 => OpenSSL::Digest::MD5,
89
+ Digest::SHA1 => OpenSSL::Digest::SHA1,
90
+ Digest::SHA2 => OpenSSL::Digest::SHA256,
41
91
  }
42
92
 
43
- def initialize(hash={}, options={})
44
- options = DEFAULT_OPTIONS.merge(options)
93
+ # Create a new sign/verify context, passing in a Hash full of data that is to be signed or
94
+ # verified. The new SignedHash will store a reference to the raw data, so be careful not to
95
+ # modify the data hash in a way that will influence the outcome of sign/verify!
96
+ #
97
+ # @param [Hash] hash the actual data that is to be signed
98
+ # @option opts [Class] :digest hash-algorithm class from Ruby's Digest module MD5, SHA1 or SHA2; default SHA1
99
+ # @option opts [true,false] :envelope use the OpenSSL EVP API if true, or raw-crypto API if false; default false
100
+ # @option opts [#dump] :encoding serialization method for dumping hash data; default DefaultEncoding
101
+ # @option opts [OpenSSL::PKey] :public_key key to use when verifying digital signatures
102
+ # @option opts [OpenSSL::PKey] :private_key key to use when computing digital signatures
103
+ #
104
+ # @see DefaultEncoding
105
+ def initialize(hash={}, opts={})
106
+ opts = DEFAULT_OPTIONS.merge(opts)
45
107
  @hash = hash
46
- @digest = options[:digest]
47
- @encoding = options[:encoding]
48
- @public_key = options[:public_key]
49
- @private_key = options[:private_key]
108
+ @digest = opts[:digest]
109
+ @encoding = opts[:encoding]
110
+ @envelope = !!opts[:envelope]
111
+ @public_key = opts[:public_key]
112
+ @private_key = opts[:private_key]
50
113
  duck_type_check
51
114
  end
52
115
 
116
+ # Produce a digital signature of the hash contents, including the expiration timestamp
117
+ # of the signature. The caller must provide the exact same hash and expires_at in order
118
+ # to successfully verify the signature.
119
+ #
120
+ # @param [Time] expires_at
121
+ # @return [String] a binary signature of the hash's contents
53
122
  def sign(expires_at)
54
123
  raise ArgumentError, "Cannot sign; missing private_key" unless @private_key
55
124
  raise ArgumentError, "expires_at must be a Time in the future" unless time_check(expires_at)
56
125
 
57
126
  metadata = {:expires_at => expires_at}
58
- @private_key.private_encrypt( digest( encode( canonicalize( frame(@hash, metadata) ) ) ) )
127
+ encoded = encode(canonicalize(frame(@hash, metadata)))
128
+
129
+ if @envelope
130
+ digest = DIGEST_MAP[@digest].new(encoded)
131
+
132
+ @private_key.sign(digest, encoded)
133
+ else
134
+ digest = @digest.new.update(encoded).digest
135
+ @private_key.private_encrypt(digest)
136
+ end
59
137
  end
60
138
 
139
+ # Verify a digital signature of the hash's contents. In order for the signature to verify,
140
+ # the expires_at, signature and hash contents must be identical to those used by the
141
+ # signer.
142
+ #
143
+ # @param [String] signature a binary signature to verify
144
+ # @param [Time] expires_at
145
+ # @return [true] always returns true (except when it raises)
146
+ # @raise [ExpiredSignature] if the signature is expired
147
+ # @raise [InvalidSignature] if the signature is invalid
61
148
  def verify!(signature, expires_at)
62
149
  raise ArgumentError, "Cannot verify; missing public_key" unless @public_key
63
150
 
64
- metadata = {:expires_at => expires_at}
65
- expected = digest( encode( canonicalize( frame(@hash, metadata) ) ) )
66
- actual = @public_key.public_decrypt(signature)
67
- raise SecurityError, "Signature mismatch: expected #{expected}, got #{actual}" unless actual == expected
68
- raise SecurityError, "The signature has expired (or expires_at is not a Time)" unless time_check(expires_at)
151
+ metadata = {:expires_at => expires_at}
152
+ plaintext = encode( canonicalize( frame(@hash, metadata) ) )
153
+
154
+ if @envelope
155
+ digest = DIGEST_MAP[@digest].new
156
+ result = @public_key.verify(digest, signature, plaintext)
157
+ raise InvalidSignature, "Signature verification failed" unless true == result
158
+ else
159
+ expected = @digest.new.update(plaintext).digest
160
+ actual = @public_key.public_decrypt(signature)
161
+ raise InvalidSignature, "Signature mismatch: expected #{expected}, got #{actual}" unless actual == expected
162
+ end
163
+
164
+ raise ExpiredSignature, "The signature has expired (or expires_at is not a Time)" unless time_check(expires_at)
165
+
166
+ true
69
167
  end
70
168
 
169
+ # Verify a digital signature of the hash's contents. In order for the signature to verify,
170
+ # the expires_at, signature and hash contents must be identical to those used by the
171
+ # signer.
172
+ #
173
+ # @param [String] signature a binary signature to verify
174
+ # @param [Time] expires_at
175
+ # @return [true] if the signature and expiration verify OK
176
+ # @return [false] if the signature or expiration failed to verify
71
177
  def verify(signature, expires_at)
72
178
  verify!(signature, expires_at)
73
- true
74
179
  rescue Exception => e
75
180
  false
76
181
  end
77
182
 
183
+ # Free the inner Hash.
78
184
  def method_missing(meth, *args)
79
185
  @hash.__send__(meth, *args)
80
186
  end
81
187
 
188
+ # Free the inner Hash.
189
+ def respond_to?(meth)
190
+ super || @hash.respond_to?(meth)
191
+ end
192
+
82
193
  private
83
194
 
84
195
  def duck_type_check
85
- unless @digest.is_a?(Class) &&
86
- @digest.instance_methods.include?(str_or_symb('update')) &&
87
- @digest.instance_methods.include?(str_or_symb('digest'))
88
- raise ArgumentError, "Digest class must respond to #update and #digest instance methods"
196
+ unless DIGEST_MAP.key?(@digest)
197
+ raise ArgumentError, "Digest must be a built-in Ruby Digest class: MD5, SHA1 or SHA2"
89
198
  end
90
199
  unless @encoding.respond_to?(str_or_symb('dump'))
91
200
  raise ArgumentError, "Encoding class/module/object must respond to .dump method"
@@ -102,22 +211,25 @@ module RightSupport::Crypto
102
211
  RUBY_VERSION > '1.9' ? method.to_sym : method.to_s
103
212
  end
104
213
 
214
+ # Ensure that an expiration time is in the future.
105
215
  def time_check(t)
106
216
  t.is_a?(Time) && (t >= Time.now)
107
217
  end
108
218
 
219
+ # Incorporate the hash and its signature metadata into an enclosing hash.
109
220
  def frame(data, metadata) # :nodoc:
110
221
  {:data => data, :metadata => metadata}
111
222
  end
112
223
 
113
- def digest(input) # :nodoc:
114
- @digest.new.update(input).digest
115
- end
116
-
224
+ # Encode a canonicalized representation of the hash.
117
225
  def encode(input)
118
226
  @encoding.dump(input)
119
227
  end
120
228
 
229
+ # Canonicalize the hash (and any nested data) by transforming it deterministically into a
230
+ # structure of arrays-in-arrays whose elements are ordered according to the lexical ordering
231
+ # of hash keys. Canonicalization ensures that the signer and verifier agree on the contents
232
+ # of the thing being signed irrespective of Ruby version, CPU architecture, etc.
121
233
  def canonicalize(input) # :nodoc:
122
234
  case input
123
235
  when Hash
@@ -25,7 +25,15 @@ module RightSupport
25
25
  # A namespace for cryptographic functionality.
26
26
  #
27
27
  module Crypto
28
+ # Exception indicating that a cryptographic signature is invalid. This can happen for several
29
+ # reasons:
30
+ # * someone tampered with the signature
31
+ # * someone tampered with the signed data
32
+ # * the public key being used to verify the signature is mismatched with the signing key
33
+ class InvalidSignature < SecurityError; end
28
34
 
35
+ # Exception indicating that a cryptographic signature has expired.
36
+ class ExpiredSignature < SecurityError; end
29
37
  end
30
38
  end
31
39
 
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{right_support}
8
- s.version = "2.8.1"
8
+ s.version = "2.8.2"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Tony Spataro", "Sergey Sergyenko", "Ryan Williamson", "Lee Kirchhoff", "Alexey Karpik", "Scott Messier"]
12
- s.date = %q{2013-08-28}
12
+ s.date = %q{2013-09-06}
13
13
  s.description = %q{A toolkit of useful, reusable foundation code created by RightScale.}
14
14
  s.email = %q{support@rightscale.com}
15
15
  s.extra_rdoc_files = [
@@ -143,6 +143,7 @@ Gem::Specification.new do |s|
143
143
  s.summary = %q{Reusable foundation code.}
144
144
 
145
145
  if s.respond_to? :specification_version then
146
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
146
147
  s.specification_version = 3
147
148
 
148
149
  if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
@@ -7,54 +7,67 @@ describe RightSupport::Crypto::SignedHash do
7
7
  @expires_at = Time.at(Time.now.to_i + 60*60) #one hour from now
8
8
  end
9
9
 
10
- context :sign do
11
- it 'computes a signature' do
12
- signature = RightSupport::Crypto::SignedHash.new(@data, :private_key=>@rsa_key).sign(@expires_at)
13
- signature.should_not be_nil
14
- RightSupport::Crypto::SignedHash.new(@data, :public_key=>@rsa_key).verify(signature, @expires_at).should be_true
15
- end
16
- end
17
-
18
- context :verify do
19
- before(:each) do
20
- @signature = RightSupport::Crypto::SignedHash.new(@data, :private_key=>@rsa_key).sign(@expires_at)
21
- @hash = RightSupport::Crypto::SignedHash.new(@data, :public_key=>@rsa_key)
22
- end
10
+ [true, false].each do |use_envelope|
11
+ context "given :envelope=>#{use_envelope} option" do
12
+ context '#sign' do
13
+ it 'computes a signature' do
14
+ signature = RightSupport::Crypto::SignedHash.
15
+ new(@data, :private_key=>@rsa_key, :envelope=>use_envelope).sign(@expires_at)
16
+ signature.should_not be_nil
23
17
 
24
- context 'when the signature and data are good' do
25
- it 'returns true' do
26
- @hash.verify(@signature, @expires_at).should be_true
18
+ RightSupport::Crypto::SignedHash.
19
+ new(@data, :public_key=>@rsa_key, :envelope=>use_envelope).
20
+ verify(signature, @expires_at).should be_true
21
+ end
27
22
  end
28
- end
29
23
 
30
- context 'when expires_at is in the past' do
31
- it 'returns false' do
32
- @hash.verify(@signature, Time.at(@expires_at.to_i - 86400)).should be_false
33
- end
34
- end
24
+ context '#verify' do
25
+ before(:each) do
26
+ @signature = RightSupport::Crypto::SignedHash.
27
+ new(@data, :private_key=>@rsa_key, :envelope=>use_envelope).
28
+ sign(@expires_at)
35
29
 
36
- context 'when expires_at has been tampered with' do
37
- it 'returns false' do
38
- @hash.verify(@signature + 'xyzzy', @expires_at).should be_false
39
- end
40
- end
30
+ @hash = RightSupport::Crypto::SignedHash.
31
+ new(@data, :public_key=>@rsa_key, :envelope=>use_envelope)
32
+ end
41
33
 
42
- context 'when the data is bad' do
43
- before(:each) do
44
- modified_data = @data.dup
45
- modified_data[modified_data.keys.first] = 'gabba gabba hey!'
46
- @modified_hash = RightSupport::Crypto::SignedHash.new(modified_data, :public_key=>@rsa_key)
47
- end
48
- it 'returns false' do
49
- @modified_hash.verify(@signature, @expires_at).should be_false
50
- end
51
- end
34
+ context 'when the signature and data are good' do
35
+ it 'returns true' do
36
+ @hash.verify(@signature, @expires_at).should be_true
37
+ end
38
+ end
39
+
40
+ context 'when expires_at is in the past' do
41
+ it 'returns false' do
42
+ @hash.verify(@signature, Time.at(@expires_at.to_i - 86400)).should be_false
43
+ end
44
+ end
45
+
46
+ context 'when expires_at has been tampered with' do
47
+ it 'returns false' do
48
+ @hash.verify(@signature + 'xyzzy', @expires_at).should be_false
49
+ end
50
+ end
51
+
52
+ context 'when the data is bad' do
53
+ before(:each) do
54
+ modified_data = @data.dup
55
+ modified_data[modified_data.keys.first] = 'gabba gabba hey!'
56
+ @modified_hash = RightSupport::Crypto::SignedHash.
57
+ new(modified_data, :public_key=>@rsa_key, :envelope=>use_envelope)
58
+ end
59
+ it 'returns false' do
60
+ @modified_hash.verify(@signature, @expires_at).should be_false
61
+ end
62
+ end
52
63
 
53
- context 'when the signature is bad' do
54
- it 'returns false' do
55
- @signature << 'xyzzy'
56
- @hash.verify(@signature, @expires_at).should be_false
64
+ context 'when the signature is bad' do
65
+ it 'returns false' do
66
+ @signature << 'xyzzy'
67
+ @hash.verify(@signature, @expires_at).should be_false
68
+ end
69
+ end
57
70
  end
58
71
  end
59
72
  end
60
- end
73
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: right_support
3
3
  version: !ruby/object:Gem::Version
4
- hash: 45
4
+ hash: 43
5
5
  prerelease: false
6
6
  segments:
7
7
  - 2
8
8
  - 8
9
- - 1
10
- version: 2.8.1
9
+ - 2
10
+ version: 2.8.2
11
11
  platform: ruby
12
12
  authors:
13
13
  - Tony Spataro
@@ -20,7 +20,7 @@ autorequire:
20
20
  bindir: bin
21
21
  cert_chain: []
22
22
 
23
- date: 2013-09-06 00:00:00 -07:00
23
+ date: 2013-10-03 00:00:00 -07:00
24
24
  default_executable:
25
25
  dependencies:
26
26
  - !ruby/object:Gem::Dependency