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 +1 -1
- data/lib/right_support/crypto/signed_hash.rb +136 -24
- data/lib/right_support/crypto.rb +8 -0
- data/right_support.gemspec +3 -2
- data/spec/crypto/signed_hash_spec.rb +55 -42
- metadata +4 -4
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.8.
|
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
|
-
:
|
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
|
-
|
44
|
-
|
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 =
|
47
|
-
@encoding =
|
48
|
-
@
|
49
|
-
@
|
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
|
-
|
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
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
86
|
-
|
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
|
-
|
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
|
data/lib/right_support/crypto.rb
CHANGED
@@ -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
|
|
data/right_support.gemspec
CHANGED
@@ -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.
|
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-
|
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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
end
|
40
|
-
end
|
30
|
+
@hash = RightSupport::Crypto::SignedHash.
|
31
|
+
new(@data, :public_key=>@rsa_key, :envelope=>use_envelope)
|
32
|
+
end
|
41
33
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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:
|
4
|
+
hash: 43
|
5
5
|
prerelease: false
|
6
6
|
segments:
|
7
7
|
- 2
|
8
8
|
- 8
|
9
|
-
-
|
10
|
-
version: 2.8.
|
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-
|
23
|
+
date: 2013-10-03 00:00:00 -07:00
|
24
24
|
default_executable:
|
25
25
|
dependencies:
|
26
26
|
- !ruby/object:Gem::Dependency
|