right_support 2.8.1 → 2.8.2

Sign up to get free protection for your applications and to get access to all the features.
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