diff --git a/app/services/encrypted_doc_storage/doc_writer.rb b/app/services/encrypted_doc_storage/doc_writer.rb new file mode 100644 index 00000000000..3d2e0555108 --- /dev/null +++ b/app/services/encrypted_doc_storage/doc_writer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class DocWriter + Result = Struct.new( + :name, + :encryption_key, + ) + + def write(image:, data_store: LocalStorage) + name = SecureRandom.uuid + storage = data_store.new + + storage.write_image( + encrypted_image: aes_cipher.encrypt(image, key), + name:, + ) + + Result.new( + name:, + encryption_key: Base64.strict_encode64(key), + ) + end + + private + + def aes_cipher + @aes_cipher ||= Encryption::AesCipherV2.new + end + + def key + @key ||= SecureRandom.bytes(32) + end + end +end diff --git a/app/services/encrypted_doc_storage/local_storage.rb b/app/services/encrypted_doc_storage/local_storage.rb new file mode 100644 index 00000000000..d048677bdbb --- /dev/null +++ b/app/services/encrypted_doc_storage/local_storage.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class LocalStorage + def write_image(encrypted_image:, name:) + FileUtils.mkdir_p(tmp_document_storage_dir) + + File.open(tmp_document_storage_dir.join(name), 'wb') do |f| + f.write(encrypted_image) + end + end + + private + + def tmp_document_storage_dir + Rails.root.join('tmp', 'encrypted_doc_storage') + end + end +end diff --git a/app/services/encrypted_doc_storage/s3_storage.rb b/app/services/encrypted_doc_storage/s3_storage.rb new file mode 100644 index 00000000000..8c4a219d6d6 --- /dev/null +++ b/app/services/encrypted_doc_storage/s3_storage.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EncryptedDocStorage + class S3Storage + def write_image(encrypted_image:, name:) + s3_client.put_object( + bucket:, + body: encrypted_image, + key: name, + ) + end + + private + + def s3_client + Aws::S3::Client.new( + http_open_timeout: 5, + http_read_timeout: 5, + compute_checksums: false, + ) + end + + def bucket + IdentityConfig.store.encrypted_document_storage_s3_bucket + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 0c72eaaba56..7e2e22b5e96 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -130,6 +130,7 @@ enable_load_testing_mode: false enable_rate_limiting: true enable_test_routes: true enable_usps_verification: true +encrypted_document_storage_s3_bucket: 'test-bucket' event_disavowal_expiration_hours: 240 facial_match_general_availability_enabled: true feature_idv_force_gpo_verification_enabled: false @@ -522,6 +523,7 @@ production: email_registrations_per_ip_track_only_mode: true enable_test_routes: false enable_usps_verification: false + encrypted_document_storage_s3_bucket: '' facial_match_general_availability_enabled: false feature_select_email_to_share_enabled: false idv_sp_required: true diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9ea782ad0d8..c1cf56b0c6f 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -148,6 +148,7 @@ def self.store config.add(:enable_rate_limiting, type: :boolean) config.add(:enable_test_routes, type: :boolean) config.add(:enable_usps_verification, type: :boolean) + config.add(:encrypted_document_storage_s3_bucket, type: :string) config.add(:event_disavowal_expiration_hours, type: :integer) config.add(:facial_match_general_availability_enabled, type: :boolean) config.add(:feature_idv_force_gpo_verification_enabled, type: :boolean) diff --git a/spec/services/encrypted_doc_storage/doc_writer_spec.rb b/spec/services/encrypted_doc_storage/doc_writer_spec.rb new file mode 100644 index 00000000000..d413f8f4704 --- /dev/null +++ b/spec/services/encrypted_doc_storage/doc_writer_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::DocWriter do + describe '#write' do + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + + subject do + EncryptedDocStorage::DocWriter.new + end + + it 'encrypts the document and writes it to storage' do + result = subject.write(image:) + + key = Base64.strict_decode64(result.encryption_key) + aes_cipher = Encryption::AesCipherV2.new + + written_image = aes_cipher.decrypt( + File.read(file_path(result.name)), + key, + ) + + # cleanup + File.delete(file_path(result.name)) + + expect(written_image).to eq(image) + end + + it 'uses LocalStorage by default' do + expect_any_instance_of(EncryptedDocStorage::LocalStorage).to receive(:write_image).once + expect_any_instance_of(EncryptedDocStorage::S3Storage).to_not receive(:write_image) + + subject.write(image:) + end + + context 'when S3Storage is passed in' do + it 'uses S3' do + expect_any_instance_of(EncryptedDocStorage::S3Storage).to receive(:write_image).once + expect_any_instance_of(EncryptedDocStorage::LocalStorage).not_to receive(:write_image) + + subject.write( + image:, + data_store: EncryptedDocStorage::S3Storage, + ) + end + end + + def file_path(uuid) + Rails.root.join('tmp', 'encrypted_doc_storage', uuid) + end + end +end diff --git a/spec/services/encrypted_doc_storage/local_storage_spec.rb b/spec/services/encrypted_doc_storage/local_storage_spec.rb new file mode 100644 index 00000000000..fc476df3378 --- /dev/null +++ b/spec/services/encrypted_doc_storage/local_storage_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::LocalStorage do + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + let(:encrypted_image) do + Encryption::AesCipherV2.new.encrypt(image, SecureRandom.bytes(32)) + end + + describe '#write_image' do + it 'writes the document to the disk' do + name = SecureRandom.uuid + + EncryptedDocStorage::LocalStorage.new.write_image( + encrypted_image:, + name:, + ) + path = Rails.root.join('tmp', 'encrypted_doc_storage', name) + + f = File.new(path, 'rb') + result = f.read + f.close + + # cleanup + File.delete(path) + + expect(result).to eq(encrypted_image) + end + end +end diff --git a/spec/services/encrypted_doc_storage/s3_storage_spec.rb b/spec/services/encrypted_doc_storage/s3_storage_spec.rb new file mode 100644 index 00000000000..9ef5db410fa --- /dev/null +++ b/spec/services/encrypted_doc_storage/s3_storage_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe EncryptedDocStorage::S3Storage do + subject { EncryptedDocStorage::S3Storage.new } + let(:img_path) { Rails.root.join('app', 'assets', 'images', 'logo.svg') } + let(:image) { File.read(img_path) } + let(:encrypted_image) do + Encryption::AesCipherV2.new.encrypt(image, SecureRandom.bytes(32)) + end + + describe '#write_image' do + let(:stubbed_s3_client) { Aws::S3::Client.new(stub_responses: true) } + + before do + allow(subject).to receive(:s3_client).and_return(stubbed_s3_client) + allow(stubbed_s3_client).to receive(:put_object) + end + + it 'writes the document to S3' do + name = '123abc' + + subject.write_image(encrypted_image:, name:) + + expect(stubbed_s3_client).to have_received(:put_object).with( + bucket: IdentityConfig.store.encrypted_document_storage_s3_bucket, + key: name, + body: encrypted_image, + ) + end + end +end