veil 0.2.0

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.
@@ -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