Skip to content

Commit

Permalink
Merge pull request #2413 from 18F/stages/rc-2018-08-02-patch-4
Browse files Browse the repository at this point in the history
Deploy RC 63.4 to int
  • Loading branch information
stevegsa authored Aug 6, 2018
2 parents 2c96c29 + d1680f0 commit 84ff4a1
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 69 deletions.
6 changes: 5 additions & 1 deletion app/services/encryption/encryptors/session_encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ module Encryptors
class SessionEncryptor
include Encodable

delegate :encrypt, to: :deprecated_encryptor
def encrypt(plaintext)
aes_ciphertext = AesEncryptor.new.encrypt(plaintext, aes_encryption_key)
kms_ciphertext = KmsClient.new.encrypt(aes_ciphertext)
encode(kms_ciphertext)
end

def decrypt(ciphertext)
return deprecated_encryptor.decrypt(ciphertext) if legacy?(ciphertext)
Expand Down
45 changes: 41 additions & 4 deletions app/services/encryption/kms_client.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'base64'

module Encryption
class KmsClient
include Encodable
Expand Down Expand Up @@ -27,16 +29,51 @@ def use_kms?(ciphertext)
end

def encrypt_kms(plaintext)
ciphertext_blob = aws_client.encrypt(
if plaintext.bytesize > 4096
encrypt_in_chunks(plaintext)
else
KEY_TYPE[:KMS] + encrypt_raw_kms(plaintext)
end
end

# chunk plaintext into ~4096 byte chunks, but not less than 1024 bytes in a chunk if chunking.
# we do this by counting how many chunks we have and adding one.
# :reek:FeatureEnvy
def encrypt_in_chunks(plaintext)
plain_size = plaintext.bytesize
number_chunks = plain_size / 4096
chunk_size = plain_size / (1 + number_chunks)
ciphertext_set = plaintext.scan(/.{1,#{chunk_size}}/m).map(&method(:encrypt_raw_kms))
KEY_TYPE[:KMS] + ciphertext_set.map { |chunk| Base64.strict_encode64(chunk) }.to_json
end

def encrypt_raw_kms(plaintext)
raise ArgumentError, 'kms plaintext exceeds 4096 bytes' if plaintext.bytesize > 4096
aws_client.encrypt(
key_id: Figaro.env.aws_kms_key_id,
plaintext: plaintext
).ciphertext_blob
KEY_TYPE[:KMS] + ciphertext_blob
end

# :reek:DuplicateMethodCall
def decrypt_kms(ciphertext)
kms_input = ciphertext.sub(KEY_TYPE[:KMS], '')
aws_client.decrypt(ciphertext_blob: kms_input).plaintext
raw_ciphertext = ciphertext.sub(KEY_TYPE[:KMS], '')
if raw_ciphertext[0] == '[' && raw_ciphertext[-1] == ']'
decrypt_chunked_kms(raw_ciphertext)
else
decrypt_raw_kms(raw_ciphertext)
end
end

def decrypt_chunked_kms(raw_ciphertext)
ciphertext_set = JSON.parse(raw_ciphertext).map { |chunk| Base64.strict_decode64(chunk) }
ciphertext_set.map(&method(:decrypt_raw_kms)).join('')
rescue JSON::ParserError, ArgumentError
decrypt_raw_kms(raw_ciphertext)
end

def decrypt_raw_kms(raw_ciphertext)
aws_client.decrypt(ciphertext_blob: raw_ciphertext).plaintext
rescue Aws::KMS::Errors::InvalidCiphertextException
raise EncryptionError, 'Aws::KMS::Errors::InvalidCiphertextException'
end
Expand Down
29 changes: 14 additions & 15 deletions spec/services/encryption/encryptors/session_encryptor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
let(:plaintext) { '{ "foo": "bar" }' }

describe '#encrypt' do
it 'returns ciphertext created by the deprecated session encryptor' do
expected_ciphertext = '123abc'

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)
it 'returns a KMS wrapped AES encrypted ciphertext' do
aes_encryptor = instance_double(Encryption::Encryptors::AesEncryptor)
kms_client = instance_double(Encryption::KmsClient)
allow(aes_encryptor).to receive(:encrypt).
with(plaintext, Figaro.env.session_encryption_key[0...32]).
and_return('aes output')
allow(kms_client).to receive(:encrypt).
with('aes output').
and_return('kms output')
allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(aes_encryptor)
allow(Encryption::KmsClient).to receive(:new).and_return(kms_client)

expected_ciphertext = Base64.strict_encode64('kms output')

ciphertext = subject.encrypt(plaintext)

Expand All @@ -30,12 +34,7 @@
end

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
let(:ciphertext) { Encryption::Encryptors::SessionEncryptor.new.encrypt(plaintext) }

it 'decrypts the ciphertext' do
expect(subject.decrypt(ciphertext)).to eq(plaintext)
Expand Down
184 changes: 135 additions & 49 deletions spec/services/encryption/kms_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,165 @@
let(:password_pepper) { '1' * 32 }
let(:local_plaintext) { 'local plaintext' }
let(:local_ciphertext) { 'local ciphertext' }
let(:kms_plaintext) { 'kms plaintext' }
let(:kms_ciphertext) { 'kms ciphertext' }
let(:kms_enabled) { true }

before do
allow(Figaro.env).to receive(:password_pepper).and_return(password_pepper)

encryptor = Encryption::Encryptors::AesEncryptor.new
allow(encryptor).to receive(:encrypt).
with(local_plaintext, password_pepper).
and_return(local_ciphertext)
allow(encryptor).to receive(:decrypt).
with(local_ciphertext, password_pepper).
and_return(local_plaintext)
allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor)

stub_aws_kms_client(kms_plaintext, kms_ciphertext)
allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled)
end

describe '#encrypt' do
context 'with KMS enabled' do
it 'uses KMS to encrypt the plaintext' do
result = subject.encrypt(kms_plaintext)
context 'with kms plaintext less than 4k' do
let(:kms_plaintext) { 'kms plaintext' }
let(:kms_ciphertext) { 'kms ciphertext' }
let(:kms_enabled) { true }

before do
allow(Figaro.env).to receive(:password_pepper).and_return(password_pepper)

encryptor = Encryption::Encryptors::AesEncryptor.new
allow(encryptor).to receive(:encrypt).
with(local_plaintext, password_pepper).
and_return(local_ciphertext)
allow(encryptor).to receive(:decrypt).
with(local_ciphertext, password_pepper).
and_return(local_plaintext)
allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor)

stub_aws_kms_client(kms_plaintext, kms_ciphertext)
allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled)
end

describe '#encrypt' do
context 'with KMS enabled' do
it 'uses KMS to encrypt the plaintext' do
result = subject.encrypt(kms_plaintext)

expect(result).to eq('KMSx' + kms_ciphertext)
end
end

context 'without KMS enabled' do
let(:kms_enabled) { false }

expect(result).to eq('KMSx' + kms_ciphertext)
it 'uses the password pepper to encrypt the plaintext and signs it' do
result = subject.encrypt(local_plaintext)

expect(result).to eq(local_ciphertext)
end
end
end

context 'without KMS enabled' do
let(:kms_enabled) { false }
describe '#decrypt' do
context 'with KMS enabled' do
it 'uses KMS to decrypt a ciphertext encrypted with KMS' do
result = subject.decrypt('KMSx' + kms_ciphertext)

expect(result).to eq(kms_plaintext)
end

it 'uses the password pepper to encrypt the plaintext and signs it' do
result = subject.encrypt(local_plaintext)
it 'uses the password pepper to decrypt a legacy ciphertext encrypted without KMS' do
result = subject.decrypt(local_ciphertext)

expect(result).to eq(local_ciphertext)
expect(result).to eq(local_plaintext)
end
end

context 'without KMS enabled' do
let(:kms_enabled) { false }

it 'uses the password pepper to decrypt a ciphertext' do
result = subject.decrypt(local_ciphertext)

expect(result).to eq(local_plaintext)
end
end
end

describe '#looks_like_kms?' do
it 'returns true for kms encrypted data' do
expect(subject.class.looks_like_kms?('KMSx' + kms_ciphertext)).to eq(true)
end

it 'returns false for non kms encrypted data' do
expect(subject.class.looks_like_kms?('abcdef.' + kms_ciphertext)).to eq(false)
end
end
end

describe '#decrypt' do
context 'with KMS enabled' do
it 'uses KMS to decrypt a ciphertext encrypted with KMS' do
result = subject.decrypt('KMSx' + kms_ciphertext)
context 'with kms plaintext greater than 4k' do
let(:long_kms_plaintext) { SecureRandom.random_bytes(4096 * 1.8) }
let(:long_kms_plaintext_bytesize) { long_kms_plaintext.bytesize }
let(:long_kms_plaintext_chunksize) { long_kms_plaintext_bytesize / 2 }
let(:kms_ciphertext) { %w[chunk1 chunk2].map { |c| Base64.strict_encode64(c) }.to_json }
let(:kms_enabled) { true }

before do
allow(Figaro.env).to receive(:password_pepper).and_return(password_pepper)

encryptor = Encryption::Encryptors::AesEncryptor.new
allow(encryptor).to receive(:encrypt).
with(local_plaintext, password_pepper).
and_return(local_ciphertext)
allow(encryptor).to receive(:decrypt).
with(local_ciphertext, password_pepper).
and_return(local_plaintext)
allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor)

stub_mapped_aws_kms_client(
long_kms_plaintext[0..long_kms_plaintext_chunksize - 1] => 'chunk1',
long_kms_plaintext[long_kms_plaintext_chunksize..-1] => 'chunk2'
)
allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled)
end

describe '#encrypt' do
context 'with KMS enabled' do
it 'uses KMS to encrypt the plaintext' do
result = subject.encrypt(long_kms_plaintext)

expect(result).to eq(kms_plaintext)
expect(result).to eq('KMSx' + kms_ciphertext)
end
end

it 'uses the password pepper to decrypt a legacy ciphertext encrypted without KMS' do
result = subject.decrypt(local_ciphertext)
context 'without KMS enabled' do
let(:kms_enabled) { false }

expect(result).to eq(local_plaintext)
it 'uses the password pepper to encrypt the plaintext and signs it' do
result = subject.encrypt(local_plaintext)

expect(result).to eq(local_ciphertext)
end
end
end

context 'without KMS enabled' do
let(:kms_enabled) { false }
describe '#decrypt' do
context 'with KMS enabled' do
it 'uses KMS to decrypt a ciphertext encrypted with KMS' do
result = subject.decrypt('KMSx' + kms_ciphertext)

expect(result).to eq(long_kms_plaintext)
end

it 'uses the password pepper to decrypt a ciphertext' do
result = subject.decrypt(local_ciphertext)
it 'uses the password pepper to decrypt a legacy ciphertext encrypted without KMS' do
result = subject.decrypt(local_ciphertext)

expect(result).to eq(local_plaintext)
expect(result).to eq(local_plaintext)
end
end
end
end

describe '#looks_like_kms?' do
it 'returns true for kms encrypted data' do
expect(subject.class.looks_like_kms?('KMSx' + kms_ciphertext)).to eq(true)
context 'without KMS enabled' do
let(:kms_enabled) { false }

it 'uses the password pepper to decrypt a ciphertext' do
result = subject.decrypt(local_ciphertext)

expect(result).to eq(local_plaintext)
end
end
end

it 'returns false for non kms encrypted data' do
expect(subject.class.looks_like_kms?('abcdef.' + kms_ciphertext)).to eq(false)
describe '#looks_like_kms?' do
it 'returns true for kms encrypted data' do
expect(subject.class.looks_like_kms?('KMSx' + kms_ciphertext)).to eq(true)
end

it 'returns false for non kms encrypted data' do
expect(subject.class.looks_like_kms?('abcdef.' + kms_ciphertext)).to eq(false)
end
end
end
end
15 changes: 15 additions & 0 deletions spec/support/aws_kms_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ def stub_aws_kms_client(random_key = random_str, ciphered_key = random_str)
[random_key, ciphered_key]
end

def stub_mapped_aws_kms_client(forward = {})
reverse = forward.invert
aws_key_id = Figaro.env.aws_kms_key_id
Aws.config[:kms] = {
stub_responses: {
encrypt: lambda { |context|
{ ciphertext_blob: forward[context.params[:plaintext]], key_id: aws_key_id }
},
decrypt: lambda { |context|
{ plaintext: reverse[context.params[:ciphertext_blob]], key_id: aws_key_id }
},
},
}
end

def stub_aws_kms_client_invalid_ciphertext(ciphered_key = random_str)
aws_key_id = Figaro.env.aws_kms_key_id
Aws.config[:kms] = {
Expand Down

0 comments on commit 84ff4a1

Please sign in to comment.