diff --git a/app/services/pii/cacher.rb b/app/services/pii/cacher.rb index d414197bf4a..27870abb8c5 100644 --- a/app/services/pii/cacher.rb +++ b/app/services/pii/cacher.rb @@ -13,17 +13,26 @@ def initialize(user, user_session) def save(user_password, profile = user.active_profile) decrypted_pii = profile.decrypt_pii(user_password) if profile - save_decrypted_pii(decrypted_pii) if decrypted_pii + save_decrypted_pii(decrypted_pii, profile.id) if decrypted_pii rotate_fingerprints(profile) if stale_fingerprints?(profile) user_session[:decrypted_pii] end - def save_decrypted_pii(decrypted_pii) + def save_decrypted_pii(decrypted_pii, profile_id = nil) user_session[:decrypted_pii] = decrypted_pii.to_json + + if profile_id.present? && IdentityConfig.store.session_encrypted_profiles_write_enabled + Pii::ProfileCacher.new(user, user_session).save_decrypted_pii(decrypted_pii, profile_id) + end + nil end - def fetch + def fetch(profile_id = nil) + if profile_id.present? && IdentityConfig.store.session_encrypted_profiles_read_enabled + return Pii::ProfileCacher.new(user, user_session).fetch(profile_id) + end + pii_string = fetch_string return nil unless pii_string @@ -37,6 +46,7 @@ def exists_in_session? def delete user_session.delete(:decrypted_pii) user_session.delete(:encrypted_pii) + Pii::ProfileCacher.new(user, user_session).delete end private diff --git a/app/services/pii/profile_cacher.rb b/app/services/pii/profile_cacher.rb new file mode 100644 index 00000000000..def7b7cc2e9 --- /dev/null +++ b/app/services/pii/profile_cacher.rb @@ -0,0 +1,69 @@ +module Pii + class ProfileCacher + attr_reader :user, :user_session + + def initialize(user, user_session) + @user = user + @user_session = user_session + end + + def save(user_password, profile = user.active_profile) + decrypted_pii = profile.decrypt_pii(user_password) if profile + save_decrypted_pii(decrypted_pii, profile.id) if decrypted_pii + rotate_fingerprints(profile, decrypted_pii) if stale_fingerprints?(profile, decrypted_pii) + decrypted_pii + end + + def save_decrypted_pii(decrypted_pii, profile_id) + kms_encrypted_pii = SessionEncryptor.new.kms_encrypt(decrypted_pii.to_json) + + user_session[:encrypted_profiles] ||= {} + user_session[:encrypted_profiles][profile_id.to_s] = kms_encrypted_pii + end + + def fetch(profile_id) + return unless user_session[:encrypted_profiles].present? + + encrypted_profile_pii = user_session[:encrypted_profiles][profile_id.to_s] + return unless encrypted_profile_pii.present? + + decrypted_profile_pii_json = SessionEncryptor.new.kms_decrypt(encrypted_profile_pii) + Pii::Attributes.new_from_json(decrypted_profile_pii_json) + end + + def exists_in_session? + user_session[:encrypted_profiles].present? + end + + def delete + user_session.delete(:encrypted_profiles) + end + + private + + def rotate_fingerprints(profile, pii) + KeyRotator::HmacFingerprinter.new.rotate( + user: user, + profile: profile, + pii_attributes: pii, + ) + end + + def stale_fingerprints?(profile, pii) + stale_ssn_signature?(profile, pii) || + stale_compound_pii_signature?(profile, pii) + end + + def stale_ssn_signature?(profile, pii) + return false unless profile.present? && pii.present? + Pii::Fingerprinter.stale?(pii.ssn, profile.ssn_signature) + end + + def stale_compound_pii_signature?(profile, pii) + return false unless profile.present? && pii.present? + compound_pii = Profile.build_compound_pii(pii) + return false unless compound_pii + Pii::Fingerprinter.stale?(compound_pii, profile.name_zip_birth_year_signature) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index c5b4ac666ca..b8a1229e1ef 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -298,6 +298,8 @@ seed_agreements_data: true service_provider_request_ttl_hours: 24 session_check_delay: 30 session_check_frequency: 30 +session_encrypted_profiles_read_enabled: true +session_encrypted_profiles_write_enabled: true session_encryptor_alert_enabled: false session_timeout_in_minutes: 15 session_timeout_warning_seconds: 150 @@ -478,6 +480,8 @@ production: scrypt_cost: 10000$8$1$ secret_key_base: seed_agreements_data: false + session_encrypted_profiles_read_enabled: false + session_encrypted_profiles_write_enabled: false session_encryption_key: skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:dev", "urn:gov:gsa:SAML:2.0.profiles:sp:sso:int"]' state_tracking_enabled: false diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 2b46412557a..ce7339d6abb 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -426,6 +426,8 @@ def self.build_store(config_map) config.add(:ses_configuration_set_name, type: :string) config.add(:session_check_delay, type: :integer) config.add(:session_check_frequency, type: :integer) + config.add(:session_encrypted_profiles_read_enabled, type: :boolean) + config.add(:session_encrypted_profiles_write_enabled, type: :boolean) config.add(:session_encryption_key, type: :string) config.add(:session_encryptor_alert_enabled, type: :boolean) config.add(:session_timeout_in_minutes, type: :integer) diff --git a/spec/services/pii/profile_cacher_spec.rb b/spec/services/pii/profile_cacher_spec.rb new file mode 100644 index 00000000000..07cad278b77 --- /dev/null +++ b/spec/services/pii/profile_cacher_spec.rb @@ -0,0 +1,158 @@ +require 'rails_helper' + +RSpec.describe Pii::ProfileCacher do + let(:password) { 'salty peanuts are best' } + let(:user) { create(:user, :with_phone, password: password) } + let(:user_session) { {}.with_indifferent_access } + + let(:active_pii) do + Pii::Attributes.new( + first_name: 'Test', + last_name: 'Testerson', + dob: '2023-01-01', + zipcode: '10000', + ssn: '123-45-6789', + ) + end + let(:active_profile) do + profile = create(:profile, :active, :verified, user: user) + profile.encrypt_pii(active_pii, password) + profile + end + + let(:pending_pii) do + Pii::Attributes.new( + first_name: 'Test2', + last_name: 'Testerson2', + dob: '2023-01-01', + zipcode: '10000', + ssn: '999-99-9999', + ) + end + let(:pending_profile) do + profile = create(:profile, :verified, :verify_by_mail_pending, user: user) + profile.encrypt_pii(pending_pii, password) + profile + end + + subject { described_class.new(user, user_session) } + + describe '#save' do + it 'writes decrypted PII to user_session for multiple profiles' do + decrypted_active_pii = subject.save(password, active_profile) + decrypted_pending_pii = subject.save(password, pending_profile) + + expect(decrypted_active_pii).to eq(active_pii) + expect(decrypted_pending_pii).to eq(pending_pii) + + encrypted_active_session_pii = user_session[:encrypted_profiles][active_profile.id.to_s] + decrypted_active_session_pii = SessionEncryptor.new.kms_decrypt(encrypted_active_session_pii) + expect(decrypted_active_session_pii).to eq(active_pii.to_json) + + encrypted_pending_session_pii = user_session[:encrypted_profiles][pending_profile.id.to_s] + decrypted_pending_session_pii = SessionEncryptor.new.kms_decrypt( + encrypted_pending_session_pii, + ) + expect(decrypted_pending_session_pii).to eq(pending_pii.to_json) + end + + it 'updates PII bundle fingerprints when keys are rotated' do + old_ssn_signature = active_profile.ssn_signature + old_compound_pii_fingerprint = active_profile.name_zip_birth_year_signature + + rotate_all_keys + + # Create a new user object to drop the memoized encrypted attributes + reloaded_user = User.find(user.id) + + described_class.new(reloaded_user, user_session).save(password, active_profile) + + active_profile.reload + + expect(active_profile.ssn_signature).to_not eq(old_ssn_signature) + expect(active_profile.name_zip_birth_year_signature).to_not eq(old_compound_pii_fingerprint) + end + + it 'does not attempt to rotate nil attributes' do + cacher = described_class.new(user, user_session) + rotate_all_keys + + expect { cacher.save(password, nil) }.to_not raise_error + end + + it 'does not raise an error if pii fingerprint is nil but attributes are present' do + # The name_zip_birth_year_signature column was added after users had + # ecrypted PII. As a result, those users may have a profile with valid PII + # and a nil value here. Caching the PII into the session for those users + # should update the signature column without raising an error + active_profile.update!(name_zip_birth_year_signature: nil) + + subject.save(password, active_profile) + + expect(active_profile.reload.name_zip_birth_year_signature).to_not be_nil + end + + it 'raises an encryption error for an incorrect password' do + expect do + subject.save('incorrect password', active_profile) + end.to raise_error(Encryption::EncryptionError) + end + end + + describe '#fetch' do + it 'fetches decrypted PII from user_session' do + user_session[:encrypted_profiles] = { + '123' => SessionEncryptor.new.kms_encrypt(active_pii.to_json), + '456' => SessionEncryptor.new.kms_encrypt(pending_pii.to_json), + } + + result = subject.fetch(123) + + expect(result).to eq(active_pii) + end + + it 'returns nil if the encrypted profiles are not present' do + result = subject.fetch(123) + + expect(result).to eq(nil) + end + + it 'returns nil a profile has not been decrypted and loaded into the session' do + user_session[:encrypted_profiles] = { + '456' => SessionEncryptor.new.kms_encrypt(pending_pii.to_json), + } + + result = subject.fetch(123) + + expect(result).to eq(nil) + end + end + + describe '#exists_in_session?' do + it 'returns true if the encrypted profiles are in the session' do + user_session[:encrypted_profiles] = { + '123' => SessionEncryptor.new.kms_encrypt(active_pii.to_json), + '456' => SessionEncryptor.new.kms_encrypt(pending_pii.to_json), + } + + expect(subject.exists_in_session?).to eq(true) + end + + it 'returns false if the encrypted profiles are in the session' do + expect(subject.exists_in_session?).to eq(false) + end + end + + describe '#delete' do + it 'deletes the encrypted profiles from the session' do + user_session[:encrypted_profiles] = { + '123' => SessionEncryptor.new.kms_encrypt(active_pii.to_json), + '456' => SessionEncryptor.new.kms_encrypt(pending_pii.to_json), + } + + subject.delete + + expect(user_session).to eq({}) + end + end +end