diff options
-rw-r--r-- | chef/lib/chef/encrypted_data_bag_item.rb | 127 | ||||
-rw-r--r-- | chef/spec/unit/encrypted_data_bag_item_spec.rb | 172 | ||||
-rw-r--r-- | chef/spec/unit/knife/data_bag_create_spec.rb | 5 | ||||
-rw-r--r-- | chef/spec/unit/knife/data_bag_edit_spec.rb | 13 | ||||
-rw-r--r-- | chef/spec/unit/knife/data_bag_from_file_spec.rb | 16 |
5 files changed, 320 insertions, 13 deletions
diff --git a/chef/lib/chef/encrypted_data_bag_item.rb b/chef/lib/chef/encrypted_data_bag_item.rb index 048ab8d57e..c20d67c92d 100644 --- a/chef/lib/chef/encrypted_data_bag_item.rb +++ b/chef/lib/chef/encrypted_data_bag_item.rb @@ -50,6 +50,126 @@ class Chef::EncryptedDataBagItem DEFAULT_SECRET_FILE = "/etc/chef/encrypted_data_bag_secret" ALGORITHM = 'aes-256-cbc' + class UnsupportedEncryptedDataBagItemFormat < StandardError + end + + class DecryptionFailure < StandardError + end + + #=== Decryptor + # For backwards compatibility, Chef implements decryption/deserialization for + # older encrypted data bag item formats in addition to the current version. + # Each decryption/deserialization strategy is implemented as a class in this + # namespace. For convenience the factory method +Decryptor.for()+ can be used + # to create an instance of the appropriate strategy for the given encrypted + # data bag value. + module Decryptor + + # Detects the encrypted data bag item format version and instantiates a + # decryptor object for that version. Call #for_decrypted_item on the + # resulting object to decrypt and deserialize it. + def self.for(encrypted_value, key) + case format_version_of(encrypted_value) + when 1 + Version1Decryptor.new(encrypted_value, key) + when 0 + Version0Decryptor.new(encrypted_value, key) + else + raise UnsupportedEncryptedDataBagItemFormat, + "This version of chef does not support encrypted data bag item format version '#{format_version}'" + end + end + + def self.format_version_of(encrypted_value) + if encrypted_value.respond_to?(:key) + encrypted_value["version"] + else + 0 + end + end + + class Version1Decryptor + + attr_reader :encrypted_data + attr_reader :key + + def initialize(encrypted_data, key) + @encrypted_data = encrypted_data + @key = key + end + + def for_decrypted_item + Yajl::Parser.parse(decrypted_data)["json_wrapper"] + end + + + def encrypted_bytes + Base64.decode64(@encrypted_data["encrypted_data"]) + end + + def iv + Base64.decode64(@encrypted_data["iv"]) + end + + def decrypted_data + @decrypted_data ||= begin + plaintext = openssl_decryptor.update(encrypted_bytes) + plaintext << openssl_decryptor.final + rescue OpenSSL::Cipher::CipherError => e + raise DecryptionFailure, "Error decrypting data bag value: '#{e.message}'. Most likely the provided key is incorrect" + end + end + + def openssl_decryptor + @openssl_decryptor ||= begin + d = OpenSSL::Cipher::Cipher.new(ALGORITHM) + d.decrypt + d.key = Digest::SHA256.digest(key) + d.iv = iv + d + end + end + + end + + class Version0Decryptor + + attr_reader :encrypted_data + attr_reader :key + + def initialize(encrypted_data, key) + @encrypted_data = encrypted_data + @key = key + end + + def for_decrypted_item + YAML.load(decrypted_data) + end + + def decrypted_data + @decrypted_data ||= begin + plaintext = openssl_decryptor.update(encrypted_bytes) + plaintext << openssl_decryptor.final + rescue OpenSSL::Cipher::CipherError => e + raise DecryptionFailure, "Error decrypting data bag value: '#{e.message}'. Most likely the provided key is incorrect" + end + end + + def encrypted_bytes + Base64.decode64(@encrypted_data) + end + + def openssl_decryptor + @openssl_decryptor ||= begin + d = OpenSSL::Cipher::Cipher.new(ALGORITHM) + d.decrypt + d.pkcs5_keyivgen(key) + d + end + end + end + end + def initialize(enc_hash, secret) @enc_hash = enc_hash @secret = secret @@ -60,7 +180,7 @@ class Chef::EncryptedDataBagItem if key == "id" || value.nil? value else - self.class.decrypt_value(value, @secret) + Decryptor.for(value, @secret).for_decrypted_item end end @@ -88,7 +208,6 @@ class Chef::EncryptedDataBagItem end def self.load(data_bag, name, secret = nil) - path = "data/#{data_bag}/#{name}" raw_hash = Chef::DataBagItem.load(data_bag, name) secret = secret || self.load_secret self.new(raw_hash, secret) @@ -98,10 +217,6 @@ class Chef::EncryptedDataBagItem Base64.encode64(self.cipher(:encrypt, value.to_yaml, key)) end - def self.decrypt_value(value, key) - YAML.load(self.cipher(:decrypt, Base64.decode64(value), key)) - end - def self.load_secret(path=nil) path = path || Chef::Config[:encrypted_data_bag_secret] || DEFAULT_SECRET_FILE secret = case path diff --git a/chef/spec/unit/encrypted_data_bag_item_spec.rb b/chef/spec/unit/encrypted_data_bag_item_spec.rb index 0b052b56c6..ce12528804 100644 --- a/chef/spec/unit/encrypted_data_bag_item_spec.rb +++ b/chef/spec/unit/encrypted_data_bag_item_spec.rb @@ -19,6 +19,141 @@ require 'spec_helper' require 'chef/encrypted_data_bag_item' +module Version0Encryptor + def self.encrypt_value(plaintext_data, key) + data = plaintext_data.to_yaml + + cipher = OpenSSL::Cipher::Cipher.new("aes-256-cbc") + cipher.encrypt + cipher.pkcs5_keyivgen(key) + encrypted_bytes = cipher.update(data) + encrypted_bytes << cipher.final + Base64.encode64(encrypted_bytes) + end +end + +# Encryption/serialization code from Chef 11. +class Version1Encryptor + ALGORITHM = "aes-256-cbc" + + attr_reader :key + attr_reader :plaintext_data + + # Create a new Encryptor for +data+, which will be encrypted with the given + # +key+. + # + # === Arguments: + # * data: An object of any type that can be serialized to json + # * key: A String representing the desired passphrase + # * iv: The optional +iv+ parameter is intended for testing use only. When + # *not* supplied, Encryptor will use OpenSSL to generate a secure random + # IV, which is what you want. + def initialize(plaintext_data, key, iv=nil) + @plaintext_data = plaintext_data + @key = key + @iv = iv && Base64.decode64(iv) + end + + # Returns a wrapped and encrypted version of +plaintext_data+ suitable for + # using as the value in an encrypted data bag item. + def for_encrypted_item + { + "encrypted_data" => encrypted_data, + "iv" => Base64.encode64(iv), + "version" => 1 + } + end + + # Generates or returns the IV. + def iv + # Generated IV comes from OpenSSL::Cipher::Cipher#random_iv + # This gets generated when +openssl_encryptor+ gets created. + openssl_encryptor if @iv.nil? + @iv + end + + # Generates (and memoizes) an OpenSSL::Cipher::Cipher object and configures + # it for the specified iv and encryption key. + def openssl_encryptor + @openssl_encryptor ||= begin + encryptor = OpenSSL::Cipher::Cipher.new(ALGORITHM) + encryptor.encrypt + @iv ||= encryptor.random_iv + encryptor.iv = @iv + encryptor.key = Digest::SHA256.digest(key) + encryptor + end + end + + # Encrypts and Base64 encodes +serialized_data+ + def encrypted_data + @encrypted_data ||= begin + enc_data = openssl_encryptor.update(serialized_data) + enc_data << openssl_encryptor.final + Base64.encode64(enc_data) + end + end + + # Wraps the data in a single key Hash (JSON Object) and converts to JSON. + # The wrapper is required because we accept values (such as Integers or + # Strings) that do not produce valid JSON when serialized without the + # wrapper. + def serialized_data + Chef::JSONCompat.to_json(:json_wrapper => plaintext_data) + end +end + + +describe Chef::EncryptedDataBagItem::Decryptor do + context "when decrypting a version 1 (JSON+aes-256-cbc+random iv) encrypted value" do + before do + @encryptor = Version1Encryptor.new({"foo" => "bar"}, "passwd") + @encrypted_value = @encryptor.for_encrypted_item + + @decryptor = Chef::EncryptedDataBagItem::Decryptor.for(@encrypted_value, "passwd") + end + + it "selects the correct strategy for version 1" do + @decryptor.should be_a_kind_of Chef::EncryptedDataBagItem::Decryptor::Version1Decryptor + end + + it "decrypts the encrypted value" do + @decryptor.decrypted_data.should == {"json_wrapper" => {"foo" => "bar"}}.to_json + end + + it "unwraps the encrypted data and returns it" do + @decryptor.for_decrypted_item.should == {"foo" => "bar"} + end + + context "and the provided key is incorrect" do + before do + @decryptor = Chef::EncryptedDataBagItem::Decryptor.for(@encrypted_value, "wrong-passwd") + end + + it "raises a sensible error" do + lambda { @decryptor.for_decrypted_item }.should raise_error(Chef::EncryptedDataBagItem::DecryptionFailure) + end + end + + end + + context "when decrypting a version 0 (YAML+aes-256-cbc+no iv) encrypted value" do + before do + @encrypted_value = Version0Encryptor.encrypt_value({"foo" => "bar"}, "passwd") + + @decryptor = Chef::EncryptedDataBagItem::Decryptor.for(@encrypted_value, "passwd") + end + + it "selects the correct strategy for version 0" do + @decryptor.should be_a_kind_of(Chef::EncryptedDataBagItem::Decryptor::Version0Decryptor) + end + + it "decrypts the encrypted value" do + @decryptor.for_decrypted_item.should == {"foo" => "bar"} + end + end +end + describe Chef::EncryptedDataBagItem do before(:each) do @secret = "abc123SECRET" @@ -33,6 +168,10 @@ describe Chef::EncryptedDataBagItem do describe "encrypting" do + it "uses version 0 encryption/serialization" do + @enc_data["greeting"].should == Version0Encryptor.encrypt_value(@plain_data["greeting"], @secret) + end + it "should not encrypt the 'id' key" do @enc_data["id"].should == "item_name" end @@ -53,12 +192,7 @@ describe Chef::EncryptedDataBagItem do end end - describe "decrypting" do - before(:each) do - @enc_data = Chef::EncryptedDataBagItem.encrypt_data_bag_item(@plain_data, - @secret) - @eh = Chef::EncryptedDataBagItem.new(@enc_data, @secret) - end + shared_examples_for "a decrypted data bag item" do it "doesn't try to decrypt 'id'" do @eh["id"].should == @plain_data["id"] @@ -81,6 +215,32 @@ describe Chef::EncryptedDataBagItem do end end + describe "decrypting" do + before(:each) do + @enc_data = Chef::EncryptedDataBagItem.encrypt_data_bag_item(@plain_data, + @secret) + @eh = Chef::EncryptedDataBagItem.new(@enc_data, @secret) + end + + it_behaves_like "a decrypted data bag item" + end + + describe "when decrypting a version 1 (Chef 11.x) data bag item" do + before do + @enc_data = @plain_data.inject({}) do |encrypted, (key, value)| + if key == "id" + encrypted["id"] = value + else + encrypted[key] = Version1Encryptor.new(value, @secret).for_encrypted_item + end + encrypted + end + @eh = Chef::EncryptedDataBagItem.new(@enc_data, @secret) + end + + it_behaves_like "a decrypted data bag item" + end + describe "loading" do it "should defer to Chef::DataBagItem.load" do Chef::DataBagItem.stub(:load).with(:the_bag, "my_codes").and_return(@enc_data) diff --git a/chef/spec/unit/knife/data_bag_create_spec.rb b/chef/spec/unit/knife/data_bag_create_spec.rb index 8639052612..2cfe1656eb 100644 --- a/chef/spec/unit/knife/data_bag_create_spec.rb +++ b/chef/spec/unit/knife/data_bag_create_spec.rb @@ -75,6 +75,11 @@ describe Chef::Knife::DataBagCreate do @knife.should_receive(:create_object).and_yield(@plain_data) data_bag_item = Chef::DataBagItem.from_hash(@enc_data) data_bag_item.data_bag("sudoing_admins") + + # Random IV is used each time the data bag item is encrypted, so values + # will not be equal if we re-encrypt. + Chef::EncryptedDataBagItem.should_receive(:encrypt_data_bag_item).and_return(@enc_data) + @rest.should_receive(:post_rest).with("data", {'name' => 'sudoing_admins'}).ordered @rest.should_receive(:post_rest).with("data/sudoing_admins", data_bag_item).ordered end diff --git a/chef/spec/unit/knife/data_bag_edit_spec.rb b/chef/spec/unit/knife/data_bag_edit_spec.rb index d0222364e3..65042f695a 100644 --- a/chef/spec/unit/knife/data_bag_edit_spec.rb +++ b/chef/spec/unit/knife/data_bag_edit_spec.rb @@ -59,6 +59,19 @@ describe Chef::Knife::DataBagEdit do @enc_edited_data = Chef::EncryptedDataBagItem.encrypt_data_bag_item(@edited_data, @secret) Chef::DataBagItem.stub!(:load).with('bag_name', 'item_name').and_return(@enc_data) + + # Random IV is used each time the data bag item is encrypted, so values + # will not be equal if we encrypt same value twice. + Chef::EncryptedDataBagItem.should_receive(:encrypt_data_bag_item).and_return(@enc_edited_data) + + @secret_file = Tempfile.new("encrypted_data_bag_secret_file_test") + @secret_file.puts(@secret) + @secret_file.flush + end + + after do + @secret_file.close + @secret_file.unlink end it "decrypts and encrypts via --secret" do diff --git a/chef/spec/unit/knife/data_bag_from_file_spec.rb b/chef/spec/unit/knife/data_bag_from_file_spec.rb index 74c8a9513a..f90086c063 100644 --- a/chef/spec/unit/knife/data_bag_from_file_spec.rb +++ b/chef/spec/unit/knife/data_bag_from_file_spec.rb @@ -142,6 +142,19 @@ describe Chef::Knife::DataBagFromFile do @secret = "abc123SECRET" @enc_data = Chef::EncryptedDataBagItem.encrypt_data_bag_item(@plain_data, @secret) + + # Random IV is used each time the data bag item is encrypted, so values + # will not be equal if we re-encrypt. + Chef::EncryptedDataBagItem.should_receive(:encrypt_data_bag_item).and_return(@enc_data) + + @secret_file = Tempfile.new("encrypted_data_bag_secret_file_test") + @secret_file.puts(@secret) + @secret_file.flush + end + + after do + @secret_file.close + @secret_file.unlink end it "encrypts values when given --secret" do @@ -177,7 +190,8 @@ describe Chef::Knife::DataBagFromFile do it "prints help if given no arguments" do @knife.instance_variable_set(:@name_args, []) lambda { @knife.run }.should raise_error(SystemExit) - @stdout.string.should match(/^knife data bag from file BAG FILE|FOLDER [FILE|FOLDER..] \(options\)/) + expected = 'knife data bag from file BAG FILE|FOLDER [FILE|FOLDER..] (options)' + @stdout.string.should include(expected) end end |