gems/aws-sdk-s3/spec/encryption/client_spec.rb (580 lines of code) (raw):
# frozen_string_literal: true
require_relative '../spec_helper'
require 'base64'
require 'openssl'
module Aws
module S3
module Encryption
describe Client do
let(:master_key) do
Base64.decode64('kM5UVbhE/4rtMZJfsadYEdm2vaKFsmV2f5+URSeUCV4=')
end
let(:api_client) do
S3::Client.new(
access_key_id: 'akid',
secret_access_key: 'secret',
region: 'us-west-1',
retry_backoff: ->(c) {} # disable failed request retries
)
end
let(:options) { { client: api_client, encryption_key: master_key } }
let(:client) { Encryption::Client.new(options) }
before do
# suppress deprecation warning
allow_any_instance_of(Client).to receive(:warn)
end
describe 'configuration' do
it 'can be used with a Resource client', rbs_test: :skip do
resource = S3::Resource.new(client: client)
expect(resource.client.config).to eq(api_client.config)
end
it 'constructs a default s3 client when one is not given' do
api_client = double('client')
expect(S3::Client).to receive(:new).and_return(api_client)
client = Encryption::Client.new(encryption_key: master_key)
expect(client.client).to be(api_client)
end
it 'accepts vanilla client options' do
opts = {
region: 'us-west-2',
credentials: Credentials.new('akid', 'secret'),
encryption_key: '.' * 32
}
enc_client = Encryption::Client.new(opts)
expect(enc_client.client.config.region).to eq('us-west-2')
expect(
enc_client.client.config.credentials.access_key_id
).to eq('akid')
expect(
enc_client.client.config.credentials.secret_access_key
).to eq('secret')
end
it 'requires an encryption key or provider' do
expect do
options.delete(:encryption_key)
Encryption::Client.new(options)
end.to raise_error(
ArgumentError, /:kms_key_id, :key_provider, or :encryption_key/
)
expect do
Encryption::Client.new(options.merge(encryption_key: master_key))
end.not_to raise_error
expect do
key_provider = double('key-provider')
Encryption::Client.new(options.merge(key_provider: key_provider))
end.not_to raise_error
end
it 'constructs a key provider from a master key' do
options[:encryption_key] = master_key
expect(client.key_provider.key_for('')).to eq(master_key)
expect(client.key_provider.key_for('{}')).to eq(master_key)
expect(
client.key_provider.key_for('{"foo":"bar"}')
).to eq(master_key)
end
it 'defaults :envelope_location to :metadata' do
expect(client.envelope_location).to eq(:metadata)
end
it 'requires :envelope_location as :metadata or :instruction_file' do
expect do
Encryption::Client.new(options.merge(envelope_location: :bad))
end.to raise_error(ArgumentError, /:metadata or :instruction_file/)
expect do
Encryption::Client.new(
options.merge(envelope_location: :metadata)
)
Encryption::Client.new(
options.merge(envelope_location: :instruction_file)
)
end.not_to raise_error
end
it 'requires :materials_description to be a valid JSON document' do
options[:materials_description] = '?!'
expect { client }.to raise_error(ArgumentError, /JSON document/)
end
it 'defaults :instruction_file_suffix to ".instruction"' do
expect(client.instruction_file_suffix).to eq('.instruction')
end
it 'requires :instruction_file_suffix to be a string' do
options[:instruction_file_suffix] = true
expect { client }.to raise_error(ArgumentError, /must be a String/)
end
end
describe 'encryption methods' do
# this is the encrypted string "secret" using the fixed envelope
# keys defined below in the before(:each) block
let(:encrypted_body) { Base64.decode64('JIgXCTXpeQerPLiU6dVL4Q==') }
before(:each) do
key = Base64.decode64(
'uSwsRlIMhY1klVYrgqceqjmQMmARcNl7rEKWW+7HVvA='
)
iv = Base64.decode64('TO5mQgtOzWkTfoX4RE5tsA==')
allow_any_instance_of(OpenSSL::Cipher)
.to receive(:random_key).and_return(key)
allow_any_instance_of(OpenSSL::Cipher)
.to receive(:random_iv).and_return(iv)
end
describe '#put_object' do
it 'encrypts the data client-side' do
stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
client.put_object(bucket: 'bucket', key: 'key', body: 'secret')
expect(
a_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).with(
body: encrypted_body,
headers: {
'Content-Length' => '16',
# key is encrypted here with the master encryption key,
# then base64 encoded
'X-Amz-Meta-X-Amz-Key' => 'gX+a4JQYj7FP0y5TAAvxTz4e'\
'2l0DvOItbXByml/NPtKQcUls'\
'oGHoYR/T0TuYHcNj',
'X-Amz-Meta-X-Amz-Iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'X-Amz-Meta-X-Amz-Matdesc' => '{}',
'X-Amz-Meta-X-Amz-Unencrypted-Content-Length' => '6'
}
)
).to have_been_made.once
end
it 'encrypts an empty or missing body' do
stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
client.put_object(bucket: 'bucket', key: 'key') # body not set
expect(
a_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).with(body: /.{16}/)
).to have_been_made.once
end
it 'can store the encryption envelope in a separate object' do
stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
stub_request(
:put,
'https://bucket.s3.us-west-1.amazonaws.com/key.instruction'
)
options[:envelope_location] = :instruction_file
client.put_object(bucket: 'bucket', key: 'key', body: 'secret')
# first request stores the encryption materials in the
# instruction file
expect(
a_request(
:put,
'https://bucket.s3.us-west-1.amazonaws.com/key.instruction'
).with(
body: Json.dump(
'x-amz-key' => 'gX+a4JQYj7FP0y5TAAvxTz4e2l0DvOIt'\
'bXByml/NPtKQcUlsoGHoYR/T0TuYHcNj',
'x-amz-iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'x-amz-matdesc' => '{}'
)
)
).to have_been_made.once
# second request stores teh encrypted object
expect(
a_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).with(
body: encrypted_body,
headers: {
'Content-Length' => '16',
'X-Amz-Meta-X-Amz-Unencrypted-Content-Length' => '6'
}
)
).to have_been_made.once
end
it 'accpets a custom instruction file suffix' do
req1 = stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key.envelope'
)
req2 = stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
options[:envelope_location] = :instruction_file
options[:instruction_file_suffix] = '.envelope'
client.put_object(bucket: 'bucket', key: 'key', body: 'secret')
expect(req1).to have_been_made.once
expect(req2).to have_been_made.once
end
it 'does not set the un-encrypted md5 header' do
stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
expect_any_instance_of(EncryptHandler).to receive(:warn)
client.put_object(
bucket: 'bucket', key: 'key', body: 'secret', content_md5: 'MD5'
)
expect(
a_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).with(
body: encrypted_body
)
).to have_been_made.once
end
it 'supports encryption with an asymmetric key pair' do
stub_request(
:put, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
options[:encryption_key] = OpenSSL::PKey::RSA.generate(1024)
resp = client.put_object(
bucket: 'bucket', key: 'key', body: 'secret'
)
expect(
resp.context.http_request.body_contents
).not_to eq('secret')
end
it 'raises an error on an invalid encryption key' do
options[:encryption_key] = 123
expect do
client.put_object(bucket: 'bucket', key: 'key', body: 'secret')
end.to raise_error(ArgumentError, /invalid encryption key/)
end
end
describe '#get_object' do
def stub_encrypted_get(matdesc = '{}')
stub_request(
:get, 'https://bucket.s3.us-west-1.amazonaws.com/key'
)
.to_return(
body: encrypted_body,
headers: {
'X-Amz-Meta-X-Amz-Key' => 'gX+a4JQYj7FP0y5TAAvxTz4e'\
'2l0DvOItbXByml/NPtKQcUls'\
'oGHoYR/T0TuYHcNj',
'X-Amz-Meta-X-Amz-Iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'X-Amz-Meta-X-Amz-Matdesc' => matdesc
}
)
end
def stub_encrypted_get_chunked
cipher = OpenSSL::Cipher::AES256.new(:CBC)
cipher.encrypt
cipher.key = Base64.decode64(
'uSwsRlIMhY1klVYrgqceqjmQMmARcNl7rEKWW+7HVvA='
)
cipher.iv = Base64.decode64('TO5mQgtOzWkTfoX4RE5tsA==')
encrypted_body = cipher.update('0' * 50) + cipher.final
stub_request(
:get, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).to_return(
body: encrypted_body,
headers: {
'X-Amz-Meta-X-Amz-Key' => 'gX+a4JQYj7FP0y5TAAvxTz4e'\
'2l0DvOItbXByml/NPtKQcUls'\
'oGHoYR/T0TuYHcNj',
'X-Amz-Meta-X-Amz-Iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'X-Amz-Meta-X-Amz-Matdesc' => '{}'
}
)
end
def stub_encrypted_get_with_instruction_file(sfx = '.instruction')
stub_request(
:get, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).to_return(body: encrypted_body)
stub_request(
:get, "https://bucket.s3.us-west-1.amazonaws.com/key#{sfx}"
).to_return(
body: Json.dump(
'x-amz-key' => 'gX+a4JQYj7FP0y5TAAvxTz4e2l0DvOIt'\
'bXByml/NPtKQcUlsoGHoYR/T0TuYHcNj',
'x-amz-iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'x-amz-matdesc' => '{}'
)
)
end
it 'decrypts the object' do
stub_encrypted_get
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'decrypts the object with response target under retry' do
stub_encrypted_get_chunked
allow_any_instance_of(DecryptHandler)
.to receive(:attach_http_event_listeners)
.and_wrap_original do |m, context|
m.call(context)
context.http_response.on_data do |_chunk|
if context.retries.zero?
context.retries = 1
# 1.9.3 doesn't have Net:ReadTimeout Error
# mocking with RuntimeError instead
context.http_response.signal_error(
Seahorse::Client::NetworkingError.new(RuntimeError.new)
)
end
end
end
data = StringIO.new(String.new)
client.get_object(
bucket: 'bucket', key: 'key', response_target: data
)
expect(data.size).to eq(50)
end
it 'supports #get_object with a block' do
stub_encrypted_get
data = ''
client.get_object(bucket: 'bucket', key: 'key') do |chunk|
data += chunk
end
expect(data).to eq('secret')
end
it 'does not attempt to decrypt failed responses' do
stub_request(
:get, 'https://bucket.s3.us-west-1.amazonaws.com/key'
).to_return(status: 500)
expect do
client.get_object(bucket: 'bucket', key: 'key')
end.to raise_error(Aws::S3::Errors::ServiceError)
end
it 'loads envelope from instruction file when not in metadata' do
stub_encrypted_get_with_instruction_file
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'users the configured instruction file suffix' do
stub_encrypted_get_with_instruction_file('.envelope')
options[:instruction_file_suffix] = '.envelope'
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'gets the instruction file first with loc :instruction_file' do
stub_encrypted_get_with_instruction_file
options[:envelope_location] = :instruction_file
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'accepts :envelope_location, overriding the default location' do
stub_encrypted_get_with_instruction_file
resp = client.get_object(
bucket: 'bucket', key: 'key',
envelope_location: :instruction_file
)
expect(resp.body.read).to eq('secret')
expect(resp.context[:encryption][:envelope_location])
.to eq(:instruction_file)
expect(resp.context[:encryption][:instruction_file_suffix])
.to eq('.instruction')
end
it 'accepts :instruction_file_suffix, overriding the default' do
stub_encrypted_get_with_instruction_file('.envelope')
resp = client.get_object(
bucket: 'bucket', key: 'key',
instruction_file_suffix: '.envelope'
)
expect(resp.body.read).to eq('secret')
expect(resp.context[:encryption][:envelope_location])
.to eq(:instruction_file)
expect(resp.context[:encryption][:instruction_file_suffix])
.to eq('.envelope')
end
it 'supports multiple master keys with a key provider' do
stub_encrypted_get('MATERIALS-DESC')
key_provider = double('key-provider')
expect(key_provider).to receive(:key_for)
.with('MATERIALS-DESC').and_return(master_key)
options[:key_provider] = key_provider
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'raises an error when materials can not be found' do
stub_encrypted_get_with_instruction_file
stub_request(
:get,
'https://bucket.s3.us-west-1.amazonaws.com/key.instruction'
).to_return(status: 404)
expect do
client.get_object(bucket: 'bucket', key: 'key')
end.to raise_error(
Errors::DecryptionError, 'unable to locate encryption envelope'
)
end
it 'resets the cipher during decryption on error' do
data = encrypted_body
api_client.handle(step: :send) do |context|
http_resp = context.http_response
headers = {
'X-Amz-Meta-X-Amz-Key' => 'gX+a4JQYj7FP0y5TAAvxTz4e'\
'2l0DvOItbXByml/NPtKQcUls'\
'oGHoYR/T0TuYHcNj',
'X-Amz-Meta-X-Amz-Iv' => 'TO5mQgtOzWkTfoX4RE5tsA==',
'X-Amz-Meta-X-Amz-Matdesc' => '{}'
}
# fail first attempt, succeed second time
if context[:already_failed]
http_resp.signal_headers(200, headers)
http_resp.signal_data(data)
http_resp.signal_done
else
context[:already_failed] = true
http_resp.signal_headers(200, headers)
http_resp.signal_data(data)
http_resp.signal_error(
Seahorse::Client::NetworkingError.new(
RuntimeError.new('oops')
)
)
end
Seahorse::Client::Response.new(context: context)
end
resp = client.get_object(bucket: 'bucket', key: 'key')
expect(resp.body.read).to eq('secret')
end
it 'raises an error when it is unable to decrypt the envelope' do
stub_encrypted_get
options[:encryption_key] = '.' * 32
expect do
client.get_object(bucket: 'bucket', key: 'key')
end.to raise_error(
Errors::DecryptionError,
'decryption failed, possible incorrect key'
)
end
it 'validates the key length' do
stub_encrypted_get
options[:encryption_key] = '.' * 31
msg = 'invalid key, symmetric key required to be 16, 24, or 32 '\
'bytes in length, saw length 31'
expect do
client.get_object(bucket: 'bucket', key: 'key')
end.to raise_error(ArgumentError, msg)
end
it 'supports asymmetric keys' do
stub_encrypted_get
options[:encryption_key] = OpenSSL::PKey::RSA.generate(1024)
expect { client.get_object(bucket: 'bucket', key: 'key') }
.to raise_error(StandardError)
end
it 'does not support get with range' do
expect do
client.get_object(
bucket: 'bucket', key: 'key', range: 'BYTE-RANGE'
)
end.to raise_error(NotImplementedError, /:range not supported/)
end
end
describe '#delete_object' do
it 'delegates to S3 client' do
expect(client.client).to receive(:delete_object)
client.delete_object(bucket: 'bucket', key: 'key')
end
end
describe '#head_object' do
it 'delegates to S3 client' do
expect(client.client).to receive(:head_object)
client.head_object(bucket: 'bucket', key: 'key')
end
end
describe '#build_request' do
it 'delegates to S3 client' do
expect(client.client).to receive(:build_request)
client.build_request(:head_object, bucket: 'bucket', key: 'key')
end
end
end
describe 'kms_CBC' do
let(:kms_client) { KMS::Client.new(stub_responses: true) }
let(:client) do
Encryption::Client.new(
kms_key_id: 'kms-key-id',
kms_client: kms_client,
stub_responses: true
)
end
let(:envelope) do
{
'x-amz-wrap-alg' => 'kms',
'x-amz-cek-alg' => 'AES/CBC/PKCS5Padding',
'x-amz-iv' => 'rVucSqIJvenVa7HliO+oIw==',
'x-amz-key-v2' => Base64.strict_encode64('encrypted-object-key'),
'x-amz-matdesc' => '{"kms_cmk_id":"kms-key-id"}'
}
end
let(:plaintext_object_key) do
"\xE4^\xE3\xE0v@\x8Aq\xAF\xE7y\x10\x18\xD4X"\
"\xC2\xDC&\xF6\xDB\xCCM\x03\xAF3DD\xFF\xDA\x0Flj"
end
let(:encrypted_object_key) { 'encrypted-object-key' }
let(:random_iv) { Base64.decode64('rVucSqIJvenVa7HliO+oIw==') }
before(:each) do
allow_any_instance_of(OpenSSL::Cipher).to(
receive(:random_iv)
.and_return(random_iv)
)
end
it 'supports encryption via KMS' do
kms_client.stub_responses(
:generate_data_key,
plaintext: plaintext_object_key,
ciphertext_blob: encrypted_object_key
)
resp = client.put_object(
bucket: 'aws-sdk', key: 'foo', body: 'plain-text'
)
headers = resp.context.http_request.headers
envelope.each do |key, value|
expect(headers["x-amz-meta-#{key}"]).to eq(value)
end
expect(
Base64.encode64(resp.context.http_request.body_contents)
).to eq("4FAj3kTOIisQ+9b8/kia8g==\n")
end
it 'supports decryption via KMS w/ CBC' do
kms_client.stub_responses(:decrypt, plaintext: plaintext_object_key)
client.client.stub_responses(
:get_object,
body: Base64.decode64("4FAj3kTOIisQ+9b8/kia8g==\n"),
metadata: envelope
)
resp = client.get_object(bucket: 'aws-sdk', key: 'foo')
expect(resp.body.read).to eq('plain-text')
end
end
describe 'kms_GCM' do
let(:kms_client) { KMS::Client.new(stub_responses: true) }
let(:client) do
Encryption::Client.new(
kms_key_id: 'kms-key-id',
kms_client: kms_client,
stub_responses: true
)
end
let(:headers) do
base64_encoded = Base64.strict_encode64('encrypted-object-key')
{
'x-amz-meta-x-amz-wrap-alg' => 'kms',
'x-amz-meta-x-amz-cek-alg' => 'AES/GCM/NoPadding',
'x-amz-meta-x-amz-iv' => 'XujE1oWCO83rw1PU',
'x-amz-meta-x-amz-key-v2' => base64_encoded,
'x-amz-meta-x-amz-matdesc' => '{"kms_cmk_id":"kms-key-id"}',
'x-amz-meta-x-amz-tag-len' => '128',
'content-length' => body.bytesize
}
end
let(:body) { Base64.decode64('ZpPUtKX0PPupGaE0o7FbJw2Ov53MXfqenLA=') }
let(:plaintext_object_key) do
"\xACb.\xEB\x16\x19(\x9AJ\xE0uCA\x034z\xF6&\x7F"\
"\x8E\x0E\xC0\xD5\x1A\x88\xAF2\xB1\xEEg#\x15"
end
if OpenSSL::Cipher.ciphers.include?('aes-256-gcm')
it 'supports decryption via KMS w/ GCM' do
kms_client.stub_responses(
:decrypt, plaintext: plaintext_object_key
)
client.client.stub_responses(
:get_object,
[
# get_object resp
{
status_code: 200,
headers: headers,
body: body
},
# get_object w/range header resp
{
status_code: 200,
headers: headers.merge('content-length' => '16'),
body: body.bytes.to_a[-16..-1].pack('C*')
}
]
)
resp = client.get_object(bucket: 'aws-sdk', key: 'foo')
expect(resp.body.read).to eq('plain-text')
end
end
end
end
end
end
end