Skip to content
Permalink
main
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
# frozen_string_literal: true
require "pathname"
require "tmpdir"
require "active_support/message_encryptor"
module ActiveSupport
class EncryptedFile
class MissingContentError < RuntimeError
def initialize(content_path)
super "Missing encrypted content file in #{content_path}."
end
end
class MissingKeyError < RuntimeError
def initialize(key_path:, env_key:)
super \
"Missing encryption key to decrypt file with. " +
"Ask your team for your master key and write it to #{key_path} or put it in the ENV['#{env_key}']."
end
end
class InvalidKeyLengthError < RuntimeError
def initialize
super "Encryption key must be exactly #{EncryptedFile.expected_key_length} characters."
end
end
CIPHER = "aes-128-gcm"
def self.generate_key
SecureRandom.hex(ActiveSupport::MessageEncryptor.key_len(CIPHER))
end
def self.expected_key_length # :nodoc:
@expected_key_length ||= generate_key.length
end
attr_reader :content_path, :key_path, :env_key, :raise_if_missing_key
def initialize(content_path:, key_path:, env_key:, raise_if_missing_key:)
@content_path = Pathname.new(content_path).yield_self { |path| path.symlink? ? path.realpath : path }
@key_path = Pathname.new(key_path)
@env_key, @raise_if_missing_key = env_key, raise_if_missing_key
end
# Returns the encryption key, first trying the environment variable
# specified by +env_key+, then trying the key file specified by +key_path+.
# If +raise_if_missing_key+ is true, raises MissingKeyError if the
# environment variable is not set and the key file does not exist.
def key
read_env_key || read_key_file || handle_missing_key
end
# Returns truthy if #key is truthy. Returns falsy otherwise. Unlike #key,
# does not raise MissingKeyError when +raise_if_missing_key+ is true.
def key?
read_env_key || read_key_file
end
# Reads the file and returns the decrypted content.
#
# Raises:
# - MissingKeyError if the key is missing and +raise_if_missing_key+ is true.
# - MissingContentError if the encrypted file does not exist or otherwise
# if the key is missing.
# - ActiveSupport::MessageEncryptor::InvalidMessage if the content cannot be
# decrypted or verified.
def read
if !key.nil? && content_path.exist?
decrypt content_path.binread
else
raise MissingContentError, content_path
end
end
def write(contents)
IO.binwrite "#{content_path}.tmp", encrypt(contents)
FileUtils.mv "#{content_path}.tmp", content_path
end
def change(&block)
writing read, &block
end
private
def writing(contents)
tmp_file = "#{Process.pid}.#{content_path.basename.to_s.chomp('.enc')}"
tmp_path = Pathname.new File.join(Dir.tmpdir, tmp_file)
tmp_path.binwrite contents
yield tmp_path
updated_contents = tmp_path.binread
write(updated_contents) if updated_contents != contents
ensure
FileUtils.rm(tmp_path) if tmp_path&.exist?
end
def encrypt(contents)
check_key_length
encryptor.encrypt_and_sign contents
end
def decrypt(contents)
encryptor.decrypt_and_verify contents
end
def encryptor
@encryptor ||= ActiveSupport::MessageEncryptor.new([ key ].pack("H*"), cipher: CIPHER, serializer: Marshal)
end
def read_env_key
ENV[env_key].presence
end
def read_key_file
@key_file_contents ||= (key_path.binread.strip if key_path.exist?)
end
def handle_missing_key
raise MissingKeyError.new(key_path: key_path, env_key: env_key) if raise_if_missing_key
end
def check_key_length
raise InvalidKeyLengthError if key&.length != self.class.expected_key_length
end
end
end