Skip to content

Commit

Permalink
Catch sending too much to kms (#2411)
Browse files Browse the repository at this point in the history
**Why**:
We can only send 4k of data to KMS for encryption. We need to
make sure we don't exceed that regardless of which method we
use so we know we can use KMS without errors.

**How**:
Raise an argument error regardless of the encyption method.
  • Loading branch information
jgsmith-usds authored and stevegsa committed Aug 6, 2018
1 parent 64df05b commit d1680f0
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 53 deletions.
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
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 d1680f0

Please sign in to comment.