veil 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,105 @@
1
+ require "veil/credential_collection/base"
2
+ require "fileutils"
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module Veil
7
+ class CredentialCollection
8
+ class ChefSecretsFile < Base
9
+ class << self
10
+ def from_file(path, opts = {})
11
+ unless File.exists?(path)
12
+ raise InvalidCredentialCollectionFile.new("#{path} does not exist")
13
+ end
14
+
15
+ new(opts.merge(path: path))
16
+ end
17
+ end
18
+
19
+ attr_reader :path, :user, :group
20
+
21
+ # Create a new ChefSecretsFile
22
+ #
23
+ # @param [Hash] opts
24
+ # a hash of options to pass to the constructor
25
+ def initialize(opts = {})
26
+ @path = (opts[:path] && File.expand_path(opts[:path])) || "/etc/opscode/private-chef-secrets.json"
27
+
28
+ import_existing = File.exists?(path) && (File.size(path) != 0)
29
+ legacy = true
30
+
31
+ if import_existing
32
+ begin
33
+ hash = JSON.parse(IO.read(path), symbolize_names: true)
34
+ rescue JSON::ParserError, Errno::ENOENT => e
35
+ raise InvalidCredentialCollectionFile.new("#{path} is not a valid credentials file:\n #{e.message}")
36
+ end
37
+
38
+ if hash.key?(:veil) && hash[:veil][:type] == "Veil::CredentialCollection::ChefSecretsFile"
39
+ opts = Veil::Utils.symbolize_keys(hash[:veil]).merge(opts)
40
+ legacy = false
41
+ end
42
+ end
43
+
44
+ @user = opts[:user]
45
+ @group = opts[:group] || @user
46
+ @version = opts[:version] || 1
47
+ super(opts)
48
+
49
+ import_legacy_credentials(hash) if import_existing && legacy
50
+ end
51
+
52
+ # Set the secrets file path
53
+ #
54
+ # @param [String] path
55
+ # a path to the private-chef-secrets.json
56
+ def path=(path)
57
+ @path = File.expand_path(path)
58
+ end
59
+
60
+ # Save the CredentialCollection to file
61
+ def save
62
+ FileUtils.mkdir_p(File.dirname(path)) unless File.directory?(File.dirname(path))
63
+
64
+ f = Tempfile.new("veil") # defaults to mode 0600
65
+ FileUtils.chown(user, group, f.path) if user
66
+ f.puts(JSON.pretty_generate(secrets_hash))
67
+ f.flush
68
+ f.close
69
+
70
+ FileUtils.mv(f.path, path)
71
+ true
72
+ end
73
+
74
+ # Return the instance as a secrets style hash
75
+ def secrets_hash
76
+ { "veil" => to_h }.merge(legacy_credentials_hash)
77
+ end
78
+
79
+ # Return the credentials in a legacy chef secrets hash
80
+ def legacy_credentials_hash
81
+ hash = Hash.new
82
+
83
+ to_h[:credentials].each do |namespace, creds|
84
+ hash[namespace] = {}
85
+ creds.each { |name, cred| hash[namespace][name] = cred[:value] }
86
+ end
87
+
88
+ hash
89
+ end
90
+
91
+ def import_legacy_credentials(hash)
92
+ hash.each do |namespace, creds_hash|
93
+ credentials[namespace.to_s] ||= Hash.new
94
+ creds_hash.each do |cred, value|
95
+ credentials[namespace.to_s][cred.to_s] = Veil::Credential.new(
96
+ name: cred.to_s,
97
+ value: value,
98
+ length: value.length
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,15 @@
1
+ module Veil
2
+ class InvalidSalt < StandardError; end
3
+ class InvalidSecret < StandardError; end
4
+ class InvalidParameter < StandardError; end
5
+ class InvalidHasher < StandardError; end
6
+ class InvalidCredentialCollectionFile < StandardError; end
7
+ class MissingParameter < StandardError; end
8
+ class NotImplmented < StandardError; end
9
+ class InvalidCredentialHash < StandardError; end
10
+ class CredentialNotFound < StandardError; end
11
+ class GroupNotFound < StandardError; end
12
+ class FileNotFound < StandardError; end
13
+ class FileNotReadable < StandardError; end
14
+ class UnknownProvider < StandardError; end
15
+ end
@@ -0,0 +1,29 @@
1
+ require "veil/utils"
2
+ require "veil/hasher/base"
3
+ require "veil/hasher/bcrypt"
4
+ require "veil/hasher/pbkdf2"
5
+
6
+ module Veil
7
+ class Hasher
8
+ DEFAULT_OPTIONS = {
9
+ type: "Veil::Hasher::PBKDF2",
10
+ iterations: 10_000,
11
+ hash_function: "SHA512"
12
+ }
13
+
14
+ class << self
15
+ #
16
+ # Create a new Hasher instance
17
+ #
18
+ # @param opts Hash<Symbol> a hash of options to pass to the constructor
19
+ #
20
+ # @example Veil::Hasher.create(type: "BCrypt", cost: 10)
21
+ # @example Veil::Hasher.create(type: "PBKDF2", iterations: 1000, hash_function: "SHA256")
22
+ #
23
+ def create(opts = {})
24
+ opts = Veil::Utils.symbolize_keys(DEFAULT_OPTIONS.merge(opts))
25
+ const_get(opts[:type]).new(opts)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ require "openssl"
2
+ require "veil/exceptions"
3
+
4
+ module Veil
5
+ class Hasher
6
+ class Base
7
+
8
+ # Hash the credential group, name and version with the stored secret and salt
9
+ #
10
+ # @param [String] group
11
+ # The service group name, eg: postgresql
12
+ #
13
+ # @param [String] name
14
+ # The credential name, eg: sql_password
15
+ #
16
+ # @param [Integer] version
17
+ # The Credential version, eg: 1
18
+ #
19
+ # @return [String] SHA512 hex digest of hashed data
20
+ def encrypt(group, name, version)
21
+ raise Veil::NotImplmented.new("#{caller[0]} has not implemented #encrypt")
22
+ end
23
+
24
+ # Return the instance as a Hash
25
+ #
26
+ # @return [Hash<Symbol,String>]
27
+ def to_hash
28
+ raise Veil::NotImplmented.new("#{caller[0]} has not implemented #to_hash")
29
+ end
30
+
31
+ private
32
+
33
+ # Create a SHA512 hex digest
34
+ #
35
+ # @param [String] data
36
+ # Data to digest
37
+ #
38
+ # @return [String]
39
+ def hex_digest(data)
40
+ OpenSSL::Digest::SHA512.hexdigest(data)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,59 @@
1
+ require "veil/hasher/base"
2
+ require "securerandom"
3
+ require "bcrypt"
4
+
5
+ module Veil
6
+ class Hasher
7
+ class BCrypt < Base
8
+ attr_reader :secret, :salt
9
+
10
+ # Create a new BCrypt
11
+ #
12
+ # @param [Hash] opts
13
+ # a hash of options to pass to the constructor
14
+ def initialize(opts = {})
15
+ if opts[:secret] && opts[:salt]
16
+ if ::BCrypt::Engine.valid_secret?(opts[:secret]) && ::BCrypt::Engine.valid_salt?(opts[:salt])
17
+ @secret = opts.delete(:secret)
18
+ @salt = opts.delete(:salt)
19
+ elsif ::BCrypt::Engine.valid_secret?(opts[:secret])
20
+ raise Veil::InvalidSalt.new("#{opts[:salt]} is not valid salt")
21
+ else
22
+ raise Veil::InvalidSecret.new("#{opts[:secret]} is not valid secret")
23
+ end
24
+ else
25
+ @secret = SecureRandom.hex(512)
26
+ @salt = ::BCrypt::Engine.generate_salt(opts[:cost] || 10)
27
+ end
28
+ end
29
+
30
+ # Hash the credential group, name and version with the stored secret and salt
31
+ #
32
+ # @param [String] group
33
+ # The service group name, eg: postgresql
34
+ #
35
+ # @param [String] name
36
+ # The credential name, eg: sql_password
37
+ #
38
+ # @param [Integer] version
39
+ # The Credential version, eg: 1
40
+ #
41
+ # @return [String] SHA512 hex digest of hashed data
42
+ def encrypt(group, name, version)
43
+ hex_digest(::BCrypt::Engine.hash_secret(hex_digest([secret, group, name, version].join), salt))
44
+ end
45
+
46
+ # Return the instance as a Hash
47
+ #
48
+ # @return [Hash<Symbol,String>]
49
+ def to_hash
50
+ {
51
+ type: self.class.name,
52
+ secret: secret,
53
+ salt: salt,
54
+ }
55
+ end
56
+ alias_method :to_h, :to_hash
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ require "veil/hasher/base"
2
+ require "securerandom"
3
+
4
+ module Veil
5
+ class Hasher
6
+ class PBKDF2 < Base
7
+ attr_reader :secret, :salt, :iterations, :hash_function
8
+
9
+ # Create a new PBKDF2
10
+ #
11
+ # @param [Hash] opts
12
+ # a hash of options to pass to the constructor
13
+ def initialize(opts = {})
14
+ @secret = opts[:secret] || SecureRandom.hex(512)
15
+ @salt = opts[:salt] || SecureRandom.hex(128)
16
+ @iterations = opts[:iterations] || 100_000
17
+ @hash_function = OpenSSL::Digest.const_get((opts[:hash_function] || "SHA512")).new
18
+ end
19
+
20
+ # Hash data with the stored secret and salt
21
+ #
22
+ # @param [String] data
23
+ # The service name and version to be encrypted with the shared key
24
+ #
25
+ # @param [Hash] opts
26
+ # Optional parameter overrides
27
+ #
28
+ # @return [String] SHA512 hex digest of hashed data
29
+ def encrypt(group, name, version)
30
+ hex_digest(OpenSSL::PKCS5.pbkdf2_hmac(
31
+ [secret, group, name, version].join,
32
+ salt,
33
+ iterations,
34
+ hash_function.length,
35
+ hash_function
36
+ ))
37
+ end
38
+
39
+ # Return the instance as a Hash
40
+ #
41
+ # @return [Hash]
42
+ def to_hash
43
+ {
44
+ type: self.class.name,
45
+ secret: secret,
46
+ salt: salt,
47
+ iterations: iterations,
48
+ hash_function: hash_function.class.name
49
+ }
50
+ end
51
+ alias_method :to_h, :to_hash
52
+ end
53
+ end
54
+ end
data/lib/veil/utils.rb ADDED
@@ -0,0 +1,25 @@
1
+ module Veil
2
+ module Utils
3
+ class << self
4
+ def symbolize_keys(hash)
5
+ new_hash = {}
6
+ hash.keys.each { |k| new_hash[k.to_sym] = hash.delete(k) }
7
+ new_hash
8
+ end
9
+
10
+ def symbolize_keys!(hash)
11
+ hash = symbolize_keys(hash)
12
+ end
13
+
14
+ def stringify_keys(hash)
15
+ new_hash = {}
16
+ hash.keys.each { |k| new_hash[k.to_s] = hash.delete(k) }
17
+ new_hash
18
+ end
19
+
20
+ def stringify_keys!(hash)
21
+ hash = stringify_keys(hash)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module Veil
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,433 @@
1
+ require "spec_helper"
2
+
3
+ describe Veil::CredentialCollection::Base do
4
+ let(:salt) { "$2a$11$4xS0IHHxU5sOYNNZ5X53Qe" }
5
+ let(:secret) { "ultrasecure" }
6
+ let(:hasher) { Veil::Hasher.create(type: "BCrypt", secret: secret, salt: salt) }
7
+
8
+ subject { described_class.new(hasher: hasher.to_h) }
9
+
10
+ describe "#self.create" do
11
+ it "returns a credential store from the hash" do
12
+ expect(described_class.create.class).to eq(described_class)
13
+ end
14
+ end
15
+
16
+ describe "#new" do
17
+ context "with hasher options" do
18
+ it "builds the hasher instance" do
19
+ hasher_hash = subject.hasher.to_h
20
+ new_instance = described_class.new(hasher: hasher_hash)
21
+ expect(new_instance.hasher.to_h).to eq(hasher_hash)
22
+ end
23
+ end
24
+
25
+ context "with credential options" do
26
+ it "builds the credentials" do
27
+ subject.add("foo", "bar", length: 22)
28
+ creds_hash = subject.to_hash[:credentials]
29
+
30
+ new_instance = described_class.new(credentials: creds_hash)
31
+ expect(new_instance["foo"]["bar"].value).to eq(subject["foo"]["bar"].value)
32
+ end
33
+ end
34
+
35
+ context "with version options" do
36
+ it "defaults to version 1" do
37
+ expect(subject.version).to eq(1)
38
+ end
39
+
40
+ it "sets the version" do
41
+ new_instance = described_class.new(version: 12)
42
+ expect(new_instance.version).to eq(12)
43
+ end
44
+ end
45
+ end
46
+
47
+ describe "#get" do
48
+ before do
49
+ subject.add("testkey0", value: "testvalue0")
50
+ subject.add("testgroup", "testkey1", value: "testvalue1")
51
+ end
52
+
53
+ it "returns the value of a given credential" do
54
+ expect(subject.get("testkey0")).to eq("testvalue0")
55
+ end
56
+
57
+ it "returns the value of a given credential in a group" do
58
+ expect(subject.get("testgroup", "testkey1")).to eq("testvalue1")
59
+ end
60
+
61
+ it "raises an error if the credential isn't found" do
62
+ expect { subject.get("dne") }.to raise_error(Veil::CredentialNotFound)
63
+ end
64
+
65
+ it "raises an error if the group isn't found" do
66
+ expect { subject.get("dne", "tesetkey") }.to raise_error(Veil::GroupNotFound)
67
+ end
68
+
69
+ it "raises an error if the credential isn't found in the group" do
70
+ expect { subject.get("testgroup", "dne") }.to raise_error(Veil::CredentialNotFound)
71
+ end
72
+
73
+ it "raises an error if the wrong number of arguments are given" do
74
+ expect { subject.get("testgroup", "tesetkey", "whoops") }.to raise_error(ArgumentError)
75
+ end
76
+ end
77
+
78
+ describe "#exist?" do
79
+ it "returns false if the key does not exist" do
80
+ expect(subject.exist?("Invalid Key")).to eq(false)
81
+ end
82
+
83
+ it "returns true if the key does exist" do
84
+ subject.add("testkey0", value: "testvalue0")
85
+ expect(subject.exist?("testkey0")).to eq(true)
86
+ end
87
+ end
88
+
89
+ describe "#add_from_file" do
90
+ context "when the file can be read" do
91
+ # using this as our input file lets us do less mocking of
92
+ # file sanity checks.
93
+ let (:input_file) { "/" }
94
+ let (:secret_content) { "a secret!" }
95
+
96
+ before do
97
+ allow(File).to receive(:read).with(input_file).and_return secret_content
98
+ end
99
+
100
+ context "with a name" do
101
+ it "adds the contents of the file as a credential" do
102
+ subject.add_from_file(input_file, "supersecret")
103
+ cred = subject["supersecret"]
104
+ expect(cred).to be_instance_of(Veil::Credential)
105
+ expect(cred.value).to eq secret_content
106
+ expect(cred.frozen).to be true
107
+ end
108
+ end
109
+
110
+ context "with a group and name" do
111
+ it "adds the contents of the file as a credential" do
112
+ subject.add_from_file(input_file, "super", "secret")
113
+ cred = subject["super"]["secret"]
114
+ expect(cred).to be_instance_of(Veil::Credential)
115
+ expect(cred.value).to eq secret_content
116
+ expect(cred.frozen).to be true
117
+ end
118
+ end
119
+ end
120
+
121
+ context "when the file can not be read" do
122
+ let (:input_file) { "/invalid" }
123
+
124
+ it "fails with a FileNotReadable error" do
125
+ expect { subject.add_from_file(input_file, "supersecret") }.to raise_error(Veil::FileNotReadable)
126
+ end
127
+ end
128
+ end
129
+
130
+ describe "#add" do
131
+ it "creates a new credential" do
132
+ subject.add("cowabunga")
133
+ expect(subject["cowabunga"]).to be_instance_of(Veil::Credential)
134
+ end
135
+
136
+ context "with a name" do
137
+ it "creates a new credential the right name" do
138
+ subject.add("cowabunga")
139
+ expect(subject["cowabunga"]).to be_instance_of(Veil::Credential)
140
+ expect(subject["cowabunga"].name).to eq("cowabunga")
141
+ end
142
+ end
143
+
144
+ context "with a group and name" do
145
+ it "creates a new credential the right group and name" do
146
+ subject.add("my_db", "password")
147
+ expect(subject["my_db"]["password"]).to be_instance_of(Veil::Credential)
148
+ expect(subject["my_db"]["password"].name).to eq("password")
149
+ end
150
+ end
151
+
152
+ context "with a name and length" do
153
+ it "creates a new credential the right name and length" do
154
+ subject.add("conspiracy", length: 23)
155
+ expect(subject["conspiracy"]).to be_instance_of(Veil::Credential)
156
+ expect(subject["conspiracy"].length).to eq(23)
157
+ expect(subject["conspiracy"].value.length).to eq(23)
158
+ end
159
+ end
160
+
161
+ context "with a group, name, and length" do
162
+ it "creates a new credential the right group and name" do
163
+ subject.add("my_db", "password", length: 15)
164
+ expect(subject["my_db"]["password"]).to be_instance_of(Veil::Credential)
165
+ expect(subject["my_db"]["password"].name).to eq("password")
166
+ expect(subject["my_db"]["password"].length).to eq(15)
167
+ expect(subject["my_db"]["password"].value.length).to eq(15)
168
+ end
169
+ end
170
+
171
+ context "with a group, name, default" do
172
+ it "creates a new credential the right group and name" do
173
+ subject.add("my_db", "password", value: "super_unison")
174
+ expect(subject["my_db"]["password"]).to be_instance_of(Veil::Credential)
175
+ expect(subject["my_db"]["password"].name).to eq("password")
176
+ expect(subject["my_db"]["password"].value).to eq("super_unison")
177
+ end
178
+ end
179
+
180
+ context "with a name and default" do
181
+ it "creates a new credential the right group and name" do
182
+ subject.add("luau", value: "new_math")
183
+ expect(subject["luau"]).to be_instance_of(Veil::Credential)
184
+ expect(subject["luau"].name).to eq("luau")
185
+ expect(subject["luau"].value).to eq("new_math")
186
+ end
187
+ end
188
+
189
+ context "when the credential already exists" do
190
+ it "does not overwrite it" do
191
+ subject.add("my_db", "password", length: 15)
192
+ val = subject["my_db"]["password"].value
193
+ subject.add("my_db", "password", value: "new-password")
194
+ expect(subject["my_db"]["password"].value).to eq(val)
195
+ end
196
+
197
+ it "returns the existing credential" do
198
+ subject.add("my_db", "password", length: 15)
199
+ my_db = subject["my_db"]["password"]
200
+ expect(subject.add("my_db", "password")).to eq(my_db)
201
+ end
202
+
203
+ context "when force: true is given as param" do
204
+ it "does overwrite it" do
205
+ subject.add("my_db", "password", length: 15)
206
+ subject.add("my_db", "password", value: 'new-password', force: true)
207
+ expect(subject["my_db"]["password"].value).to eq("new-password")
208
+ end
209
+
210
+ it "returns the new credential" do
211
+ subject.add("my_db", "password", length: 15)
212
+ expect(subject.add("my_db", "password", value: "new-password", force: true).value).to eq('new-password')
213
+ end
214
+ end
215
+
216
+ context "when force: true is given as param and :frozen is not" do
217
+ it "sets frozen to true" do
218
+ subject.add("my_db", "password", value: 'new-password', force: true)
219
+ expect(subject["my_db"]["password"].frozen).to eq(true)
220
+ end
221
+ end
222
+
223
+ context "when force: true is given as param and :frozen is false" do
224
+ it "sets frozen to false" do
225
+ subject.add("my_db", "password", value: 'new-password', force: true, frozen: false)
226
+ expect(subject["my_db"]["password"].frozen).to eq(false)
227
+ end
228
+ end
229
+
230
+ context "when force: false is given as param and :frozen is true" do
231
+ it "sets frozen to true" do
232
+ subject.add("my_db", "password", value: 'new-password', force: true, frozen: true)
233
+ expect(subject["my_db"]["password"].frozen).to eq(true)
234
+ end
235
+
236
+ end
237
+
238
+ context "when force: false is given as param and :frozen is false" do
239
+ it "sets frozen to false" do
240
+ subject.add("my_db", "password", value: 'new-password', force: true, frozen: false)
241
+ expect(subject["my_db"]["password"].frozen).to eq(false)
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ describe "#remove" do
248
+ context "with a cred" do
249
+ context "with a match" do
250
+ it "returns the value and removes the credential" do
251
+ subject.add("funk")
252
+ value = subject["funk"]
253
+ expect(subject.remove("funk")).to eq(value)
254
+ expect(subject["funk"]).to be_nil
255
+ end
256
+
257
+ context "when there is not a match" do
258
+ it "returns nil" do
259
+ expect(subject.remove("not_a_cred")).to be_nil
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ context "with a group and cred" do
266
+ context "with a match" do
267
+ it "returns the value and removes the credential" do
268
+ subject.add("grandfunk", "railroad")
269
+ value = subject["grandfunk"]["railroad"]
270
+ expect(subject.remove("grandfunk", "railroad")).to eq(value)
271
+ expect(subject["grandfunk"]["railroad"]).to be_nil
272
+ end
273
+
274
+ context "when there is not a match" do
275
+ it "returns nil" do
276
+ expect(subject.remove("nested", "thing")).to be_nil
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ describe "#rotate_hasher" do
284
+ it "creates a new hasher" do
285
+ hasher = subject.hasher
286
+ subject.rotate_hasher
287
+ expect(subject.hasher).to_not eq(hasher)
288
+ end
289
+
290
+ it "rotates all credentials" do
291
+ subject.add("foo")
292
+ foo_val = subject["foo"].value
293
+ subject.add("bar", "baz", length: 25)
294
+ baz_val = subject["bar"]["baz"].value
295
+
296
+ subject.rotate_hasher
297
+
298
+ expect(subject["foo"].value).to_not eq(foo_val)
299
+ expect(subject["foo"].version).to eq(1)
300
+ expect(subject["bar"]["baz"].value).to_not eq(baz_val)
301
+ expect(subject["bar"]["baz"].version).to eq(1)
302
+ end
303
+
304
+ context "with frozen credentials" do
305
+ it "rotates all credentials that are not frozen" do
306
+ subject.add("foo")
307
+ foo_val = subject["foo"].value
308
+ subject.add("bar", "baz", length: 25)
309
+ baz_val = subject["bar"]["baz"].value
310
+ subject.add("qux", "quux", frozen: true)
311
+ baz_val = subject["qux"]["quux"].value
312
+
313
+ subject.rotate_hasher
314
+
315
+ expect(subject["foo"].value).to_not eq(foo_val)
316
+ expect(subject["foo"].version).to eq(1)
317
+ expect(subject["bar"]["baz"].value).to_not eq(baz_val)
318
+ expect(subject["bar"]["baz"].version).to eq(1)
319
+ expect(subject["qux"]["quux"].value).to eq(baz_val)
320
+ expect(subject["qux"]["quux"].version).to eq(0)
321
+ end
322
+ end
323
+ end
324
+
325
+ describe "#rotate_credentials" do
326
+ it "doesn't create a new hasher" do
327
+ hasher = subject.hasher
328
+ subject.rotate_credentials
329
+ expect(subject.hasher).to eq(hasher)
330
+ end
331
+
332
+ it "rotates all credentials" do
333
+ subject.add("foo")
334
+ foo_val = subject["foo"].value
335
+ subject.add("bar", "baz", length: 25)
336
+ baz_val = subject["bar"]["baz"].value
337
+
338
+ subject.rotate_credentials
339
+
340
+ expect(subject["foo"].value).to_not eq(foo_val)
341
+ expect(subject["foo"].version).to eq(1)
342
+ expect(subject["bar"]["baz"].value).to_not eq(baz_val)
343
+ expect(subject["bar"]["baz"].version).to eq(1)
344
+ end
345
+
346
+ context "with frozen credentials" do
347
+ it "rotates all credentials that are not frozen" do
348
+ subject.add("foo")
349
+ foo_val = subject["foo"].value
350
+ subject.add("bar", "baz", length: 25)
351
+ baz_val = subject["bar"]["baz"].value
352
+ subject.add("qux", "quux", frozen: true)
353
+ baz_val = subject["qux"]["quux"].value
354
+
355
+ subject.rotate_credentials
356
+
357
+ expect(subject["foo"].value).to_not eq(foo_val)
358
+ expect(subject["foo"].version).to eq(1)
359
+ expect(subject["bar"]["baz"].value).to_not eq(baz_val)
360
+ expect(subject["bar"]["baz"].version).to eq(1)
361
+ expect(subject["qux"]["quux"].value).to eq(baz_val)
362
+ expect(subject["qux"]["quux"].version).to eq(0)
363
+ end
364
+ end
365
+ end
366
+
367
+ describe "#rotate" do
368
+ context "when the credential exists" do
369
+ it "rotates the credential" do
370
+ subject.add("life_choices")
371
+ old_val = subject["life_choices"].value
372
+ old_version = subject["life_choices"].version
373
+
374
+ subject.rotate("life_choices")
375
+ expect(subject["life_choices"].value).to_not eq(old_val)
376
+ expect(subject["life_choices"].version).to eq(old_version + 1)
377
+ end
378
+ end
379
+
380
+ context "when the credential does not exist" do
381
+ it "returns nil" do
382
+ expect(subject.rotate("not_a_cred")).to be_nil
383
+ end
384
+ end
385
+
386
+ context "when passed a set name only" do
387
+ it "rotates each credential" do
388
+ subject.add("desert", "black_eagle")
389
+ eagle_val = subject["desert"]["black_eagle"].value
390
+
391
+ subject.add("desert", "mercury_six")
392
+ mercury_val = subject["desert"]["mercury_six"].value
393
+
394
+ subject.rotate("desert")
395
+
396
+ expect(subject["desert"]["black_eagle"].value).to_not eq(eagle_val)
397
+ expect(subject["desert"]["black_eagle"].version).to eq(1)
398
+ expect(subject["desert"]["mercury_six"].value).to_not eq(mercury_val)
399
+ expect(subject["desert"]["mercury_six"].version).to eq(1)
400
+ end
401
+ end
402
+
403
+ context "with a frozen credential" do
404
+ it "does not rotate the credential" do
405
+ subject.add("mannequin", "republic", frozen: true)
406
+ old_val = subject["mannequin"]["republic"].value
407
+
408
+ subject.rotate("mannequin", "republic")
409
+
410
+ expect(subject["mannequin"]["republic"].value).to eq(old_val)
411
+ expect(subject["mannequin"]["republic"].version).to eq(0)
412
+ end
413
+ end
414
+ end
415
+
416
+ describe "#to_hash" do
417
+ it "returns a valid hash" do
418
+ subject.add("foo")
419
+ subject.add("bar", "baz", length: 31)
420
+ subject.add("saint", "matthew", frozen: true)
421
+
422
+ new_instance = described_class.new(subject.to_hash)
423
+ expect(new_instance["foo"].version).to eq(subject["foo"].version)
424
+ expect(new_instance["foo"].value).to eq(subject["foo"].value)
425
+ expect(new_instance["bar"]["baz"].version).to eq(subject["bar"]["baz"].version)
426
+ expect(new_instance["bar"]["baz"].value).to eq(subject["bar"]["baz"].value)
427
+ expect(new_instance["bar"]["baz"].length).to eq(subject["bar"]["baz"].length)
428
+ expect(new_instance["saint"]["matthew"].version).to eq(subject["saint"]["matthew"].version)
429
+ expect(new_instance["saint"]["matthew"].value).to eq(subject["saint"]["matthew"].value)
430
+ expect(new_instance["saint"]["matthew"].frozen).to eq(subject["saint"]["matthew"].frozen)
431
+ end
432
+ end
433
+ end