18F/identity-idp

View on GitHub
lib/session_encryptor.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
# 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
end
end