lib/session_encryptor.rb
# frozen_string_literal: true class SessionEncryptor class SensitiveKeyError < StandardError; end class SensitiveValueError < StandardError; end CIPHERTEXT_HEADER = 'v3' MINIMUM_COMPRESS_LIMIT = 300 SENSITIVE_KEYS = [ 'first_name', 'middle_name', 'last_name', 'address1', 'address2', 'city', 'state', 'zipcode', 'zip_code', 'identity_doc_address1', 'identity_doc_address2', 'identity_doc_city', 'identity_doc_zipcode', 'identity_doc_address_state', 'state_id_jurisdiction', 'same_address_as_id', 'dob', 'phone_number', 'phone', 'ssn', 'prev_address1', 'prev_address2', 'prev_city', 'prev_state', 'prev_zipcode', 'pii', 'pii_from_doc', 'pii_from_user', 'password', 'personal_key', 'email', 'email_address', 'unconfirmed_phone' ].to_set.freeze CIPHERTEXT_KEY = 't' COMPRESSED_KEY = 'c' VERSION_KEY = 'v' # 'idv/in_person' and 'idv' are used during the proofing process and can contain PII # personal keys are generated and stored in the session between requests, but are used # to decrypt PII bundles, so we treat them similarly to the PII itself. SENSITIVE_PATHS = [ ['warden.user.user.session', 'idv/in_person'], ['warden.user.user.session', 'idv'], ['warden.user.user.session', 'personal_key'], ['warden.user.user.session', 'unconfirmed_phone'], ['flash', 'flashes', 'personal_key'], ['flash', 'flashes', 'email'], ['email'], ].freeze SENSITIVE_DEFAULT_FIELDS = Idp::Constants::MOCK_IDV_APPLICANT.slice( :last_name, :address1, :city, :dob, :state_id_expiration, ).values.freeze SENSITIVE_REGEX = %r{#{SENSITIVE_DEFAULT_FIELDS.join('|')}}i def load(value) payload = MessagePack.unpack(value) ciphertext = payload[CIPHERTEXT_KEY] compressed = payload[COMPRESSED_KEY] decrypted = outer_decrypt(ciphertext) decrypted = if compressed == 1 Zlib.gunzip(decrypted) else decrypted end session = JSON.parse(decrypted).with_indifferent_access kms_decrypt_sensitive_paths!(session) session end def dump(value) value.deep_stringify_keys! kms_encrypt_sensitive_paths!(value, SENSITIVE_PATHS) alert_or_raise_if_contains_sensitive_keys!(value) plain = JSON.generate(value) alert_or_raise_if_contains_sensitive_value!(plain, value) if should_compress?(plain) { VERSION_KEY => CIPHERTEXT_HEADER, CIPHERTEXT_KEY => outer_encrypt(Zlib.gzip(plain)), COMPRESSED_KEY => 1, }.to_msgpack else { VERSION_KEY => CIPHERTEXT_HEADER, CIPHERTEXT_KEY => outer_encrypt(plain), COMPRESSED_KEY => 0, }.to_msgpack end end def kms_encrypt(text) Base64.encode64(kms_client.encrypt(text, 'context' => 'session-encryption')) end def kms_decrypt(text) kms_client.decrypt(Base64.decode64(text), 'context' => 'session-encryption') end def kms_client Encryption::KmsClient.new(kms_key_id: IdentityConfig.store.aws_kms_session_key_id) end def outer_encrypt(plaintext) Encryption::Encryptors::AesEncryptorV2.new.encrypt(plaintext, session_encryption_key) end def outer_decrypt(ciphertext) Encryption::Encryptors::AesEncryptorV2.new.decrypt(ciphertext, session_encryption_key) end private # This method extracts all of the sensitive paths that exist into a # separate hash. This separate hash is then encrypted and placed in the session. # We use #reduce to build the nested empty hash if needed. If Hash#bury # (https://bugs.ruby-lang.org/issues/11747) existed, we could use that instead. def kms_encrypt_sensitive_paths!(session, sensitive_paths) sensitive_data = {} sensitive_paths.each do |path| all_but_last_key = path[0..-2] last_key = path.last if all_but_last_key.blank? value = session.delete(last_key) else value = session.dig(*all_but_last_key)&.delete(last_key) end if value all_but_last_key.reduce(sensitive_data) do |hash, key| hash[key] ||= {} hash[key] end if all_but_last_key.blank? sensitive_data[last_key] = value else sensitive_data.dig(*all_but_last_key).store(last_key, value) end end end raise "invalid session, 'sensitive_data' is reserved key" if session['sensitive_data'].present? return if sensitive_data.blank? session['sensitive_data'] = kms_encrypt(JSON.generate(sensitive_data)) end # This method reverses the steps taken in #kms_encrypt_sensitive_paths! # The encrypted hash is decrypted and then deep merged into the session hash. # The merge must be a deep merge to avoid collisions with existing hashes in the # session. def kms_decrypt_sensitive_paths!(session) sensitive_data = session.delete('sensitive_data') return if sensitive_data.blank? sensitive_data = JSON.parse( kms_decrypt(sensitive_data), ) session.deep_merge!(sensitive_data) end def alert_or_raise_if_contains_sensitive_value!(string, hash) if SENSITIVE_REGEX.match?(string) exception = SensitiveValueError.new if IdentityConfig.store.session_encryptor_alert_enabled NewRelic::Agent.notice_error( exception, custom_params: { session_structure: hash.deep_transform_values { |_v| nil }, } ) else raise exception end end end def alert_or_raise_if_contains_sensitive_keys!(hash) hash.deep_transform_keys do |key| if SENSITIVE_KEYS.include?(key.to_s) exception = SensitiveKeyError.new("#{key} unexpectedly appeared in session") if IdentityConfig.store.session_encryptor_alert_enabled NewRelic::Agent.notice_error( exception, custom_params: { session_structure: hash.deep_transform_values { |_v| '' }, } ) else raise exception end end end end def should_compress?(value) value.bytesize >= MINIMUM_COMPRESS_LIMIT end def session_encryption_key IdentityConfig.store.session_encryption_key endend