fnando/attr_keyring

View on GitHub
lib/attr_keyring.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module AttrKeyring
  require "attr_keyring/version"
  require "keyring"
  require "attr_keyring/encoders/json_encoder"

  def self.active_record
    require "attr_keyring/active_record"
    ::AttrKeyring::ActiveRecord
  end

  def self.sequel
    require "attr_keyring/sequel"
    ::AttrKeyring::Sequel
  end

  def self.setup(target)
    target.class_eval do
      extend ClassMethods
      include InstanceMethods

      class << self
        attr_accessor :encrypted_attributes, :keyring, :keyring_column_name
      end

      self.encrypted_attributes = {}
      self.keyring = Keyring.new({}, digest_salt: "")
      self.keyring_column_name = :keyring_id
    end
  end

  module ClassMethods
    def inherited(subclass)
      super

      subclass.encrypted_attributes = encrypted_attributes.dup
      subclass.keyring = keyring
      subclass.keyring_column_name = keyring_column_name
    end

    def attr_keyring(keyring, options = {})
      self.keyring = Keyring.new(keyring, options)
    end

    def attr_encrypt(*attributes, encoder: nil)
      self.encrypted_attributes ||= {}

      attributes.each do |attribute|
        encrypted_attributes[attribute.to_sym] = {encoder: encoder}

        define_attr_encrypt_writer(attribute)
        define_attr_encrypt_reader(attribute)
      end
    end

    def define_attr_encrypt_writer(attribute)
      define_method("#{attribute}=") do |value|
        attr_encrypt_column(attribute, value)
      end
    end

    def define_attr_encrypt_reader(attribute)
      define_method(attribute) do
        attr_decrypt_column(attribute)
      end
    end
  end

  module InstanceMethods
    private def attr_encrypt_column(attribute, value)
      clear_decrypted_column_cache(attribute)
      return reset_encrypted_column(attribute) unless encryptable_value?(value)

      encoder = self.class.encrypted_attributes[attribute][:encoder]
      value = encoder.dump(value) if encoder
      value = value.to_s

      previous_keyring_id = public_send(self.class.keyring_column_name)
      encrypted_value, keyring_id, digest =
        self.class.keyring.encrypt(value, previous_keyring_id)

      public_send("#{self.class.keyring_column_name}=", keyring_id)
      public_send("encrypted_#{attribute}=", encrypted_value)

      return unless respond_to?("#{attribute}_digest=")

      public_send("#{attribute}_digest=", digest)
    end

    private def attr_decrypt_column(attribute)
      cache_name = :"@#{attribute}"

      if instance_variable_defined?(cache_name)
        return instance_variable_get(cache_name)
      end

      encrypted_value = public_send("encrypted_#{attribute}")

      return unless encrypted_value

      decrypted_value = self.class.keyring.decrypt(
        encrypted_value,
        public_send(self.class.keyring_column_name)
      )

      encoder = self.class.encrypted_attributes[attribute][:encoder]
      decrypted_value = encoder.parse(decrypted_value) if encoder

      instance_variable_set(cache_name, decrypted_value)
    end

    private def clear_decrypted_column_cache(attribute)
      cache_name = :"@#{attribute}"

      return unless instance_variable_defined?(cache_name)

      remove_instance_variable(cache_name)
    end

    private def reset_encrypted_column(attribute)
      public_send("encrypted_#{attribute}=", nil)
      if respond_to?("#{attribute}_digest=")
        public_send("#{attribute}_digest=", nil)
      end
      nil
    end

    private def migrate_to_latest_encryption_key
      return unless self.class.keyring.current_key

      keyring_id = self.class.keyring.current_key.id

      self.class.encrypted_attributes.each do |attribute, options|
        value = public_send(attribute)
        next unless encryptable_value?(value)

        encoder = options[:encoder]
        value = encoder.dump(value) if encoder

        encrypted_value, _, digest = self.class.keyring.encrypt(value)

        public_send("encrypted_#{attribute}=", encrypted_value)

        if respond_to?("#{attribute}_digest")
          public_send("#{attribute}_digest=", digest)
        end
      end

      public_send("#{self.class.keyring_column_name}=", keyring_id)
    end

    private def encryptable_value?(value)
      return false if value.nil?
      return false if value.is_a?(String) && value.empty?

      true
    end
  end
end