diff --git a/app/services/encryption/encryptors/deprecated_session_encryptor.rb b/app/services/encryption/encryptors/deprecated_session_encryptor.rb new file mode 100644 index 00000000000..fac387466f2 --- /dev/null +++ b/app/services/encryption/encryptors/deprecated_session_encryptor.rb @@ -0,0 +1,28 @@ +module Encryption + module Encryptors + class DeprecatedSessionEncryptor + def encrypt(plaintext) + user_access_key = self.class.load_or_init_user_access_key + UserAccessKeyEncryptor.new(user_access_key).encrypt(plaintext) + end + + def decrypt(ciphertext) + user_access_key = self.class.load_or_init_user_access_key + UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) + end + + def self.load_or_init_user_access_key + if @user_access_key_scrypt_hash.present? + return UserAccessKey.new(scrypt_hash: @user_access_key_scrypt_hash) + end + + key = Figaro.env.session_encryption_key + user_access_key = UserAccessKey.new( + password: key, salt: OpenSSL::Digest::SHA256.hexdigest(key) + ) + @user_access_key_scrypt_hash = user_access_key.as_scrypt_hash + user_access_key + end + end + end +end diff --git a/app/services/encryption/encryptors/session_encryptor.rb b/app/services/encryption/encryptors/session_encryptor.rb index 39e86ceeb56..df9b39b73ec 100644 --- a/app/services/encryption/encryptors/session_encryptor.rb +++ b/app/services/encryption/encryptors/session_encryptor.rb @@ -1,27 +1,33 @@ module Encryption module Encryptors class SessionEncryptor - def encrypt(plaintext) - user_access_key = self.class.load_or_init_user_access_key - UserAccessKeyEncryptor.new(user_access_key).encrypt(plaintext) - end + include Encodable + + delegate :encrypt, to: :deprecated_encryptor def decrypt(ciphertext) - user_access_key = self.class.load_or_init_user_access_key - UserAccessKeyEncryptor.new(user_access_key).decrypt(ciphertext) + return deprecated_encryptor.decrypt(ciphertext) if legacy?(ciphertext) + + aes_ciphertext = KmsClient.new.decrypt(decode(ciphertext)) + aes_encryptor.decrypt(aes_ciphertext, aes_encryption_key) + end + + private + + def legacy?(ciphertext) + ciphertext.index('.') + end + + def aes_encryptor + AesEncryptor.new + end + + def aes_encryption_key + Figaro.env.session_encryption_key[0...32] end - def self.load_or_init_user_access_key - if @user_access_key_scrypt_hash.present? - return UserAccessKey.new(scrypt_hash: @user_access_key_scrypt_hash) - end - - key = Figaro.env.session_encryption_key - user_access_key = UserAccessKey.new( - password: key, salt: OpenSSL::Digest::SHA256.hexdigest(key) - ) - @user_access_key_scrypt_hash = user_access_key.as_scrypt_hash - user_access_key + def deprecated_encryptor + DeprecatedSessionEncryptor.new end end end diff --git a/spec/services/encryption/encryptors/deprecated_session_encryptor.rb b/spec/services/encryption/encryptors/deprecated_session_encryptor.rb new file mode 100644 index 00000000000..ef88164af91 --- /dev/null +++ b/spec/services/encryption/encryptors/deprecated_session_encryptor.rb @@ -0,0 +1,79 @@ +require 'rails_helper' + +describe Encryption::Encryptors::DeprecatedSessionEncryptor do + let(:plaintext) { '{ "foo": "bar" }' } + + before do + described_class.instance_variable_set(:@user_access_key_scrypt_hash, nil) + end + + describe '#encrypt' do + it 'returns encrypted text' do + ciphertext = subject.encrypt(plaintext) + + expect(ciphertext).to_not eq(plaintext) + end + + it 'only computes an scrypt hash on the first encryption' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + subject.encrypt(plaintext) + + expect(SCrypt::Engine).to_not receive(:hash_secret) + + subject.encrypt(plaintext) + end + end + + describe '#decrypt' do + let(:ciphertext) do + result = subject.encrypt(plaintext) + described_class.instance_variable_set(:@user_access_key_scrypt_hash, nil) + result + end + + before do + # Memoize the ciphertext and purge memoized key so that encryption does not + # affect expected call counts + ciphertext + end + + it 'returns a decrypted ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end + + it 'only computes and scrypt hash on the first decryption' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + subject.decrypt(ciphertext) + + expect(SCrypt::Engine).to_not receive(:hash_secret) + + subject.decrypt(ciphertext) + end + end + + describe '.load_or_init_user_access_key' do + it 'does not return the same key object for the same salt and cost' do + expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + + key1 = described_class.load_or_init_user_access_key + key2 = described_class.load_or_init_user_access_key + + expect(key1.as_scrypt_hash).to eq(key2.as_scrypt_hash) + expect(key1).to_not eq(key2) + end + end + + it 'makes a roundtrip across multiple encryptors' do + encryptor1 = described_class.new + encryptor2 = described_class.new + + # Memoize user access key scrypt hash + encryptor1.decrypt(encryptor1.encrypt('asdf')) + encryptor2.decrypt(encryptor2.encrypt('1234')) + + encrypted_text = encryptor1.encrypt(plaintext) + expect(encryptor2.decrypt(encrypted_text)).to eq(plaintext) + end +end diff --git a/spec/services/encryption/encryptors/session_encryptor_spec.rb b/spec/services/encryption/encryptors/session_encryptor_spec.rb index fc1ab152b10..95efbf641aa 100644 --- a/spec/services/encryption/encryptors/session_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/session_encryptor_spec.rb @@ -3,77 +3,43 @@ describe Encryption::Encryptors::SessionEncryptor do let(:plaintext) { '{ "foo": "bar" }' } - before do - described_class.instance_variable_set(:@user_access_key_scrypt_hash, nil) - end - describe '#encrypt' do - it 'returns encrypted text' do - ciphertext = subject.encrypt(plaintext) - - expect(ciphertext).to_not eq(plaintext) - end - - it 'only computes an scrypt hash on the first encryption' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + it 'returns ciphertext created by the deprecated session encryptor' do + expected_ciphertext = '123abc' - subject.encrypt(plaintext) + deprecated_encryptor = Encryption::Encryptors::DeprecatedSessionEncryptor.new + expect(deprecated_encryptor).to receive(:encrypt). + with(plaintext). + and_return(expected_ciphertext) + expect(Encryption::Encryptors::DeprecatedSessionEncryptor).to receive(:new). + and_return(deprecated_encryptor) - expect(SCrypt::Engine).to_not receive(:hash_secret) + ciphertext = subject.encrypt(plaintext) - subject.encrypt(plaintext) + expect(ciphertext).to eq(expected_ciphertext) end end describe '#decrypt' do - let(:ciphertext) do - result = subject.encrypt(plaintext) - described_class.instance_variable_set(:@user_access_key_scrypt_hash, nil) - result - end - - before do - # Memoize the ciphertext and purge memoized key so that encryption does not - # affect expected call counts - ciphertext - end - - it 'returns a decrypted ciphertext' do - expect(subject.decrypt(ciphertext)).to eq(plaintext) - end - - it 'only computes and scrypt hash on the first decryption' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original - - subject.decrypt(ciphertext) + context 'with a legacy ciphertext' do + let(:ciphertext) { Encryption::Encryptors::DeprecatedSessionEncryptor.new.encrypt(plaintext) } - expect(SCrypt::Engine).to_not receive(:hash_secret) - - subject.decrypt(ciphertext) + it 'decrypts the ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end end - end - - describe '.load_or_init_user_access_key' do - it 'does not return the same key object for the same salt and cost' do - expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original - key1 = described_class.load_or_init_user_access_key - key2 = described_class.load_or_init_user_access_key - - expect(key1.as_scrypt_hash).to eq(key2.as_scrypt_hash) - expect(key1).to_not eq(key2) + context 'with a 2L-KMS ciphertext' do + let(:ciphertext) do + key = Figaro.env.session_encryption_key[0...32] + aes_ciphertext = Encryption::Encryptors::AesEncryptor.new.encrypt(plaintext, key) + kms_ciphertext = Encryption::KmsClient.new.encrypt(aes_ciphertext) + Base64.strict_encode64(kms_ciphertext) + end + + it 'decrypts the ciphertext' do + expect(subject.decrypt(ciphertext)).to eq(plaintext) + end end end - - it 'makes a roundtrip across multiple encryptors' do - encryptor1 = described_class.new - encryptor2 = described_class.new - - # Memoize user access key scrypt hash - encryptor1.decrypt(encryptor1.encrypt('asdf')) - encryptor2.decrypt(encryptor2.encrypt('1234')) - - encrypted_text = encryptor1.encrypt(plaintext) - expect(encryptor2.decrypt(encrypted_text)).to eq(plaintext) - end end