chef-encrypted-attributes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +163 -0
  3. data/CHANGELOG.md +7 -0
  4. data/INTERNAL.md +111 -0
  5. data/LICENSE +190 -0
  6. data/README.md +330 -0
  7. data/Rakefile +46 -0
  8. data/TESTING.md +45 -0
  9. data/TODO.md +20 -0
  10. data/lib/chef-encrypted-attributes.rb +19 -0
  11. data/lib/chef/encrypted_attribute.rb +218 -0
  12. data/lib/chef/encrypted_attribute/cache_lru.rb +74 -0
  13. data/lib/chef/encrypted_attribute/config.rb +200 -0
  14. data/lib/chef/encrypted_attribute/encrypted_mash.rb +122 -0
  15. data/lib/chef/encrypted_attribute/encrypted_mash/version0.rb +143 -0
  16. data/lib/chef/encrypted_attribute/encrypted_mash/version1.rb +140 -0
  17. data/lib/chef/encrypted_attribute/exceptions.rb +38 -0
  18. data/lib/chef/encrypted_attribute/local_node.rb +38 -0
  19. data/lib/chef/encrypted_attribute/remote_clients.rb +46 -0
  20. data/lib/chef/encrypted_attribute/remote_node.rb +111 -0
  21. data/lib/chef/encrypted_attribute/remote_users.rb +73 -0
  22. data/lib/chef/encrypted_attribute/search_helper.rb +144 -0
  23. data/lib/chef/encrypted_attribute/version.rb +23 -0
  24. data/lib/chef/knife/core/config.rb +19 -0
  25. data/lib/chef/knife/core/encrypted_attribute_editor_options.rb +100 -0
  26. data/lib/chef/knife/encrypted_attribute_create.rb +67 -0
  27. data/lib/chef/knife/encrypted_attribute_delete.rb +71 -0
  28. data/lib/chef/knife/encrypted_attribute_edit.rb +68 -0
  29. data/lib/chef/knife/encrypted_attribute_show.rb +86 -0
  30. data/lib/chef/knife/encrypted_attribute_update.rb +65 -0
  31. data/spec/benchmark_helper.rb +32 -0
  32. data/spec/integration_helper.rb +20 -0
  33. data/spec/spec_helper.rb +38 -0
  34. metadata +204 -0
@@ -0,0 +1,122 @@
1
+ #
2
+ # Author:: Xabier de Zuazo (<xabier@onddo.com>)
3
+ # Copyright:: Copyright (c) 2014 Onddo Labs, SL. (www.onddo.com)
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'chef/mash'
20
+ require 'chef/encrypted_attribute/exceptions'
21
+
22
+ class Chef
23
+ class EncryptedAttribute
24
+ class EncryptedMash < Mash
25
+
26
+ # This class is oriented to be easily integrable with
27
+ # chef in the future using JSONCompat
28
+
29
+ JSON_CLASS = 'x_json_class'.freeze
30
+ CHEF_TYPE = 'chef_type'.freeze
31
+ CHEF_TYPE_VALUE = 'encrypted_attribute'.freeze
32
+
33
+ VERSION_PREFIX = "#{self.name}::Version"
34
+
35
+ def initialize(enc_hs=nil)
36
+ super
37
+ self[JSON_CLASS] = self.class.name
38
+ self[CHEF_TYPE] = CHEF_TYPE_VALUE
39
+ update_from!(enc_hs) if enc_hs.kind_of?(Hash)
40
+ end
41
+
42
+ %w{encrypt decrypt can_be_decrypted_by? needs_update?}.each do |meth|
43
+ define_method(meth) do
44
+ raise NotImplementedError, "#{self.class.to_s}##{__method__} method not implemented."
45
+ end
46
+ end
47
+
48
+ def self.exists?(enc_hs)
49
+ enc_hs.kind_of?(Hash) and
50
+ enc_hs.has_key?(JSON_CLASS) and
51
+ enc_hs[JSON_CLASS] =~ /^#{Regexp.escape(Module.nesting[1].name)}/ and
52
+ enc_hs.has_key?(CHEF_TYPE) and enc_hs[CHEF_TYPE] == CHEF_TYPE_VALUE
53
+ end
54
+
55
+ def self.create(version)
56
+ klass = version_klass(version)
57
+ klass.send(:new)
58
+ end
59
+
60
+ # Serialize this object as a Hash
61
+ def to_json(*a)
62
+ for_json.to_json(*a)
63
+ end
64
+
65
+ # Returns a Hash for JSON
66
+ def for_json
67
+ to_hash
68
+ end
69
+
70
+ # Update the EncryptedMash from Hash
71
+ def update_from!(enc_hs)
72
+ unless self.class.exists?(enc_hs)
73
+ raise UnacceptableEncryptedAttributeFormat, 'Trying to construct invalid encrypted attribute. Maybe it is not encrypted?'
74
+ end
75
+ enc_hs = enc_hs.dup
76
+ enc_hs.delete(JSON_CLASS)
77
+ enc_hs.delete(CHEF_TYPE)
78
+ update(enc_hs)
79
+ end
80
+
81
+ # Create an EncryptedMash::Version from JSON Hash
82
+ def self.json_create(enc_hs)
83
+ klass = string_to_klass(enc_hs[JSON_CLASS])
84
+ if klass.nil?
85
+ raise UnsupportedEncryptedAttributeFormat, "Unknown chef-encrypted-attribute class \"#{enc_hs[JSON_CLASS]}\""
86
+ end
87
+ klass.send(:new, enc_hs)
88
+ end
89
+
90
+ protected
91
+
92
+ def self.string_to_klass(class_name)
93
+ unless class_name.kind_of?(String)
94
+ raise UnacceptableEncryptedAttributeFormat, "Bad chef-encrypted-attribute class name \"#{class_name.inspect}\""
95
+ end
96
+ begin
97
+ if RUBY_VERSION < '1.9'
98
+ class_name.split('::').inject(Kernel) { |scope, const| scope.const_get(const) }
99
+ else
100
+ class_name.split('::').inject(Kernel) { |scope, const| scope.const_get(const, scope === Kernel) }
101
+ end
102
+ rescue NameError => e
103
+ Chef::Log.error(e)
104
+ nil
105
+ end
106
+ end
107
+
108
+ def self.version_klass(version)
109
+ version = version.to_s unless version.kind_of?(String)
110
+ if version.empty?
111
+ raise UnacceptableEncryptedAttributeFormat, "Bad chef-encrypted-attribute version \"#{version.inspect}\""
112
+ end
113
+ klass = string_to_klass("#{VERSION_PREFIX}#{version}")
114
+ if klass.nil?
115
+ raise UnsupportedEncryptedAttributeFormat, "This version of chef-encrypted-attribute does not support encrypted attribute item format version: \"#{version}\""
116
+ end
117
+ klass
118
+ end
119
+
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,143 @@
1
+ #
2
+ # Author:: Xabier de Zuazo (<xabier@onddo.com>)
3
+ # Copyright:: Copyright (c) 2014 Onddo Labs, SL. (www.onddo.com)
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'chef/encrypted_attribute/encrypted_mash'
20
+ require 'chef/encrypted_attribute/exceptions'
21
+ require 'yajl'
22
+
23
+ # Version0 format: using RSA without shared secret
24
+ class Chef
25
+ class EncryptedAttribute
26
+ class EncryptedMash
27
+ class Version0 < Chef::EncryptedAttribute::EncryptedMash
28
+
29
+ def encrypt(value, public_keys)
30
+ value_json = json_encode(value)
31
+ public_keys = parse_public_keys(public_keys)
32
+ self['encrypted_data'] = rsa_encrypt_multi_key(value_json, public_keys)
33
+ self
34
+ end
35
+
36
+ def decrypt(key)
37
+ key = parse_decryption_key(key)
38
+ value_json = rsa_decrypt_multi_key(self['encrypted_data'], key)
39
+ json_decode(value_json)
40
+ # we avoid saving the decrypted value, only return it
41
+ end
42
+
43
+ def can_be_decrypted_by?(keys)
44
+ return false unless encrypted?
45
+ parse_public_keys(keys).reduce(true) do |r, k|
46
+ r and data_can_be_decrypted_by_key?(self['encrypted_data'], k)
47
+ end
48
+ end
49
+
50
+ def needs_update?(keys)
51
+ keys = parse_public_keys(keys)
52
+ not can_be_decrypted_by?(keys) && self['encrypted_data'].keys.count == keys.count
53
+ end
54
+
55
+ protected
56
+
57
+ def encrypted?
58
+ has_key?('encrypted_data') and self['encrypted_data'].kind_of?(Hash)
59
+ end
60
+
61
+ def pem_to_key(k)
62
+ k.kind_of?(OpenSSL::PKey::RSA) ? k : OpenSSL::PKey::RSA.new(k)
63
+ rescue OpenSSL::PKey::RSAError, TypeError => e
64
+ raise InvalidPrivateKey, "The provided key is invalid: #{k.inspect}"
65
+ end
66
+
67
+ def parse_public_key(key)
68
+ key = pem_to_key(key)
69
+ unless key.public?
70
+ raise InvalidPublicKey, 'Invalid public key provided.'
71
+ end
72
+ key
73
+ end
74
+
75
+ def parse_decryption_key(key)
76
+ key = pem_to_key(key)
77
+ unless key.public? and key.private?
78
+ raise InvalidPrivateKey, 'The provided key for decryption is invalid, a valid public and private key is required.'
79
+ end
80
+ unless can_be_decrypted_by?(key) # TODO optimize, node key digest is calculated multiple times
81
+ raise DecryptionFailure, 'Attribute data cannot be decrypted by the provided key.'
82
+ end
83
+ key
84
+ end
85
+
86
+ def parse_public_keys(keys)
87
+ keys = [ keys ].flatten
88
+ keys.map do |k|
89
+ parse_public_key(k)
90
+ end.uniq { |k| k.public_key.to_s.chomp }
91
+ end
92
+
93
+ def json_encode(o)
94
+ # TODO This does not check if the object is correct, should be an Array or a Hash
95
+ Yajl::Encoder.encode(o)
96
+ end
97
+
98
+ def json_decode(o)
99
+ Yajl::Parser.parse(o.to_s)
100
+ rescue Yajl::ParseError => e
101
+ raise DecryptionFailure, "#{e.class.name}: #{e.to_s}"
102
+ end
103
+
104
+ def node_key(public_key)
105
+ Digest::SHA1.hexdigest(public_key.to_der)
106
+ end
107
+
108
+ def rsa_encrypt_value(value, public_key)
109
+ Base64.encode64(public_key.public_encrypt(value))
110
+ rescue OpenSSL::PKey::RSAError => e
111
+ raise EncryptionFailure, "#{e.class.name}: #{e.to_s}"
112
+ end
113
+
114
+ def rsa_decrypt_value(value, key)
115
+ key.private_decrypt(Base64.decode64(value.to_s))
116
+ rescue OpenSSL::PKey::RSAError => e
117
+ raise DecryptionFailure, "#{e.class.name}: #{e.to_s}"
118
+ end
119
+
120
+ def rsa_encrypt_multi_key(value, public_keys)
121
+ Mash.new(Hash[
122
+ public_keys.map do |public_key|
123
+ [
124
+ node_key(public_key),
125
+ rsa_encrypt_value(value, public_key),
126
+ ]
127
+ end
128
+ ])
129
+ end
130
+
131
+ def rsa_decrypt_multi_key(enc_value, key)
132
+ enc_value = enc_value[node_key(key.public_key)]
133
+ rsa_decrypt_value(enc_value, key)
134
+ end
135
+
136
+ def data_can_be_decrypted_by_key?(enc_value, key)
137
+ enc_value.has_key?(node_key(key.public_key))
138
+ end
139
+
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,140 @@
1
+ #
2
+ # Author:: Xabier de Zuazo (<xabier@onddo.com>)
3
+ # Copyright:: Copyright (c) 2014 Onddo Labs, SL. (www.onddo.com)
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require 'chef/encrypted_attribute/encrypted_mash/version0'
20
+ require 'chef/encrypted_attribute/exceptions'
21
+
22
+ # Version1 format: using RSA with a shared secret and message authentication (HMAC)
23
+ class Chef
24
+ class EncryptedAttribute
25
+ class EncryptedMash
26
+ class Version1 < Chef::EncryptedAttribute::EncryptedMash::Version0
27
+ SYMM_ALGORITHM = 'aes-256-cbc'
28
+ HMAC_ALGORITHM = 'sha256'
29
+
30
+ def encrypt(value, public_keys)
31
+ secrets = {}
32
+ value_json = json_encode(value)
33
+ public_keys = parse_public_keys(public_keys)
34
+ # encrypt the data
35
+ encrypted_data = symmetric_encrypt_value(value_json)
36
+ secrets['data'] = encrypted_data.delete('secret') # should no include the secret in clear
37
+ self['encrypted_data'] = encrypted_data
38
+ # generate hmac (encrypt-then-mac), excluding the secret
39
+ hmac = generate_hmac(json_encode(self['encrypted_data'].sort))
40
+ secrets['hmac'] = hmac.delete('secret')
41
+ self['hmac'] = hmac
42
+ # encrypt the shared secrets
43
+ self['encrypted_secret'] = rsa_encrypt_multi_key(json_encode(secrets), public_keys)
44
+ self
45
+ end
46
+
47
+ def decrypt(key)
48
+ key = parse_decryption_key(key)
49
+ enc_value = self['encrypted_data'].dup
50
+ hmac = self['hmac'].dup
51
+ # decrypt the shared secrets
52
+ secrets = json_decode(rsa_decrypt_multi_key(self['encrypted_secret'], key))
53
+ enc_value['secret'] = secrets['data']
54
+ hmac['secret'] = secrets['hmac']
55
+ # check hmac (encrypt-then-mac -> mac-then-decrypt)
56
+ unless hmac_matches?(hmac, json_encode(self['encrypted_data'].sort))
57
+ raise DecryptionFailure, 'Error decrypting encrypted attribute: invalid hmac. Most likely the data is corrupted.'
58
+ end
59
+ # decrypt the data
60
+ value_json = symmetric_decrypt_value(enc_value)
61
+ json_decode(value_json)
62
+ end
63
+
64
+ def can_be_decrypted_by?(keys)
65
+ return false unless encrypted?
66
+ parse_public_keys(keys).reduce(true) do |r, k|
67
+ r and data_can_be_decrypted_by_key?(self['encrypted_secret'], k)
68
+ end
69
+ end
70
+
71
+ def needs_update?(keys)
72
+ keys = parse_public_keys(keys)
73
+ not can_be_decrypted_by?(keys) && self['encrypted_secret'].keys.count == keys.count
74
+ end
75
+
76
+ protected
77
+
78
+ def encrypted?
79
+ super and
80
+ self['encrypted_data'].has_key?('iv') and
81
+ self['encrypted_data']['iv'].kind_of?(String) and
82
+ self['encrypted_data'].has_key?('data') and
83
+ self['encrypted_data']['data'].kind_of?(String) and
84
+ self['encrypted_secret'].kind_of?(Hash) and
85
+ self['hmac'].kind_of?(Hash) and
86
+ self['hmac'].has_key?('data') and
87
+ self['hmac']['data'].kind_of?(String)
88
+ end
89
+
90
+ def symmetric_encrypt_value(value, algo=SYMM_ALGORITHM)
91
+ enc_value = Mash.new({ 'cipher' => algo })
92
+ begin
93
+ cipher = OpenSSL::Cipher.new(algo)
94
+ cipher.encrypt
95
+ enc_value['iv'] = Base64.encode64(cipher.iv = cipher.random_iv)
96
+ enc_value['secret'] = Base64.encode64(cipher.key = cipher.random_key)
97
+ enc_data = cipher.update(value) + cipher.final
98
+ rescue OpenSSL::Cipher::CipherError => e
99
+ raise EncryptionFailure, "#{e.class.name}: #{e.to_s}"
100
+ end
101
+ enc_value['data'] = Base64.encode64(enc_data)
102
+ enc_value
103
+ end
104
+
105
+ def symmetric_decrypt_value(enc_value, algo=SYMM_ALGORITHM)
106
+ cipher = OpenSSL::Cipher.new(enc_value['cipher'] || algo) # TODO maybe it's better to ignore [cipher] ?
107
+ cipher.decrypt
108
+ cipher.iv = Base64.decode64(enc_value['iv'])
109
+ cipher.key = Base64.decode64(enc_value['secret'])
110
+ cipher.update(Base64.decode64(enc_value['data'])) + cipher.final
111
+ rescue OpenSSL::Cipher::CipherError => e
112
+ raise DecryptionFailure, "#{e.class.name}: #{e.to_s}"
113
+ end
114
+
115
+ def generate_hmac(data, algo=HMAC_ALGORITHM)
116
+ hmac = Mash.new({ 'cipher' => algo }) # [cipher] is ignored, only as info
117
+ digest = OpenSSL::Digest.new(algo)
118
+ secret = OpenSSL::Random.random_bytes(digest.block_length)
119
+ hmac['secret'] = Base64.encode64(secret)
120
+ hmac['data'] = Base64.encode64(OpenSSL::HMAC.digest(digest, secret, data))
121
+ hmac
122
+ rescue OpenSSL::Digest::DigestError, OpenSSL::HMACError, RuntimeError => e
123
+ # RuntimeError is raised for unsupported algorithms
124
+ raise MessageAuthenticationFailure, "#{e.class.name}: #{e.to_s}"
125
+ end
126
+
127
+ def hmac_matches?(orig_hmac, data, algo=HMAC_ALGORITHM)
128
+ digest = OpenSSL::Digest.new(algo)
129
+ secret = Base64.decode64(orig_hmac['secret'])
130
+ new_hmac = Base64.encode64(OpenSSL::HMAC.digest(digest, secret, data))
131
+ orig_hmac['data'] == new_hmac
132
+ rescue OpenSSL::Digest::DigestError, OpenSSL::HMACError, RuntimeError => e
133
+ # RuntimeError is raised for unsupported algorithms
134
+ raise MessageAuthenticationFailure, "#{e.class.name}: #{e.to_s}"
135
+ end
136
+
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,38 @@
1
+ #
2
+ # Author:: Xabier de Zuazo (<xabier@onddo.com>)
3
+ # Copyright:: Copyright (c) 2014 Onddo Labs, SL. (www.onddo.com)
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ class Chef
20
+ class EncryptedAttribute
21
+
22
+ class UnsupportedEncryptedAttributeFormat < StandardError; end
23
+ class UnacceptableEncryptedAttributeFormat < StandardError; end
24
+ class DecryptionFailure < StandardError; end
25
+ class EncryptionFailure < StandardError; end
26
+ class MessageAuthenticationFailure < StandardError; end
27
+ class InvalidPublicKey < StandardError; end
28
+ class InvalidPrivateKey < StandardError; end
29
+
30
+ class InsufficientPrivileges < StandardError; end
31
+ class UserNotFound < StandardError; end
32
+
33
+ class SearchFailure < StandardError; end
34
+ class SearchFatalError < StandardError; end
35
+ class InvalidSearchKeys < StandardError; end
36
+
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ #
2
+ # Author:: Xabier de Zuazo (<xabier@onddo.com>)
3
+ # Copyright:: Copyright (c) 2014 Onddo Labs, SL. (www.onddo.com)
4
+ # License:: Apache License, Version 2.0
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ class Chef
20
+ class EncryptedAttribute
21
+ class LocalNode
22
+
23
+ # currently not used
24
+ def name
25
+ Chef::Config[:node_name]
26
+ end
27
+
28
+ def key
29
+ OpenSSL::PKey::RSA.new(open(Chef::Config[:client_key]).read())
30
+ end
31
+
32
+ def public_key
33
+ key.public_key
34
+ end
35
+
36
+ end
37
+ end
38
+ end