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 +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
|