lib/lockbox/model.rb in lockbox-0.2.5 vs lib/lockbox/model.rb in lockbox-0.3.0

- old
+ new

@@ -1,6 +1,6 @@ -class Lockbox +module Lockbox module Model def attached_encrypted(attribute, **options) warn "[lockbox] DEPRECATION WARNING: Use encrypts_attached instead" encrypts_attached(attribute, **options) end @@ -10,11 +10,11 @@ name = name.to_sym class_eval do @lockbox_attachments ||= {} - unless respond_to?(:lockbox_attachments) + if @lockbox_attachments.empty? def self.lockbox_attachments parent_attachments = if superclass.respond_to?(:lockbox_attachments) superclass.lockbox_attachments else @@ -29,11 +29,11 @@ @lockbox_attachments[name] = options end end end - def encrypts(*attributes, encode: true, **options) + def encrypts(*attributes, **options) # support objects # case options[:type] # when Date # options[:type] = :date # when Time @@ -48,21 +48,15 @@ # options[:type] = :integer # when Float # options[:type] = :float # end - raise ArgumentError, "Unknown type: #{options[:type]}" unless [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :binary, :json, :hash].include?(options[:type]) + custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize) + raise ArgumentError, "Unknown type: #{options[:type]}" unless custom_type || [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :binary, :json, :hash].include?(options[:type]) - attribute_type = - case options[:type] - when nil, :json, :hash - :string - when :integer - ActiveModel::Type::Integer.new(limit: 8) - else - options[:type] - end + activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base + raise ArgumentError, "Type not supported yet with Mongoid" if options[:type] && !activerecord attributes.each do |name| # add default options encrypted_attribute = "#{name}_ciphertext" @@ -74,38 +68,30 @@ name = name.to_sym options[:attribute] = name.to_s options[:encrypted_attribute] = encrypted_attribute - class_method_name = "generate_#{encrypted_attribute}" + options[:encode] = true unless options.key?(:encode) - class_eval do - if options[:migrating] - before_validation do - send("#{name}=", send(original_name)) if send("#{original_name}_changed?") - end - end + encrypt_method_name = "generate_#{encrypted_attribute}" + decrypt_method_name = "decrypt_#{encrypted_attribute}" + class_eval do @lockbox_attributes ||= {} - unless respond_to?(:lockbox_attributes) + if @lockbox_attributes.empty? def self.lockbox_attributes parent_attributes = if superclass.respond_to?(:lockbox_attributes) superclass.lockbox_attributes else {} end parent_attributes.merge(@lockbox_attributes || {}) end - end - raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name] - @lockbox_attributes[original_name] = options.merge(encode: encode) - - if @lockbox_attributes.size == 1 # use same approach as activerecord serialization def serializable_hash(options = nil) options = options.try(:dup) || {} options[:except] = Array(options[:except]) @@ -121,57 +107,83 @@ "#{k}: #{respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : v.inspect}" end "#<#{self.class} #{inspection.join(", ")}>" end - # needed for in-place modifications - # assigned attributes are encrypted on assignment - # and then again here - before_save do - self.class.lockbox_attributes.each do |_, lockbox_attribute| - attribute = lockbox_attribute[:attribute] + if activerecord + # needed for in-place modifications + # assigned attributes are encrypted on assignment + # and then again here + before_save do + self.class.lockbox_attributes.each do |_, lockbox_attribute| + attribute = lockbox_attribute[:attribute] - if changes.include?(attribute) - type = (self.class.try(:attribute_types) || {})[attribute] - if type && type.is_a?(ActiveRecord::Type::Serialized) + if attribute_changed_in_place?(attribute) send("#{attribute}=", send(attribute)) end end end - end - - if defined?(Mongoid::Document) && included_modules.include?(Mongoid::Document) + else def reload self.class.lockbox_attributes.each do |_, v| instance_variable_set("@#{v[:attribute]}", nil) end super end end end - serialize name, JSON if options[:type] == :json - serialize name, Hash if options[:type] == :hash + raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name] + @lockbox_attributes[original_name] = options - if respond_to?(:attribute) - attribute name, attribute_type + if activerecord + # preference: + # 1. type option + # 2. existing virtual attribute + # 3. default to string (which can later be overridden) + if options[:type] + attribute_type = + case options[:type] + when :json, :hash + :string + when :integer + ActiveModel::Type::Integer.new(limit: 8) + else + options[:type] + end - define_method("#{name}?") do - send("#{encrypted_attribute}?") + attribute name, attribute_type + + serialize name, JSON if options[:type] == :json + serialize name, Hash if options[:type] == :hash + elsif !attributes_to_define_after_schema_loads.key?(name.to_s) + attribute name, :string end + + define_method("#{name}_was") do + send(name) # writes attribute when not already set + super() + end + + # restore ciphertext as well + define_method("restore_#{name}!") do + super() + send("restore_#{encrypted_attribute}!") + end + + if ActiveRecord::VERSION::STRING >= "5.1" + define_method("#{name}_in_database") do + send(name) # writes attribute when not already set + super() + end + end else + # keep this module dead simple + # Mongoid uses changed_attributes to calculate keys to update + # so we shouldn't mess with it m = Module.new do define_method("#{name}=") do |val| - prev_val = instance_variable_get("@#{name}") - - unless val == prev_val - # custom attribute_will_change! method - unless changed_attributes.key?(name.to_s) - changed_attributes[name.to_s] = prev_val.__deep_copy__ - end - end - instance_variable_set("@#{name}", val) end define_method(name) do instance_variable_get("@#{name}") @@ -181,14 +193,36 @@ include m alias_method "#{name}_changed?", "#{encrypted_attribute}_changed?" define_method "#{name}_was" do - attribute_was(name.to_s) + ciphertext = send("#{encrypted_attribute}_was") + self.class.send(decrypt_method_name, ciphertext, context: self) end + + define_method "#{name}_change" do + ciphertexts = send("#{encrypted_attribute}_change") + ciphertexts.map { |v| self.class.send(decrypt_method_name, v, context: self) } if ciphertexts + end + + define_method "reset_#{name}!" do + instance_variable_set("@#{name}", nil) + send("reset_#{encrypted_attribute}!") + send(name) + end + + define_method "reset_#{name}_to_default!" do + instance_variable_set("@#{name}", nil) + send("reset_#{encrypted_attribute}_to_default!") + send(name) + end end + define_method("#{name}?") do + send("#{encrypted_attribute}?") + end + define_method("#{name}=") do |message| original_message = message # decrypt first for dirty tracking # don't raise error if can't decrypt previous @@ -197,79 +231,46 @@ rescue Lockbox::DecryptionError nil end # set ciphertext - ciphertext = self.class.send(class_method_name, message, context: self) + ciphertext = self.class.send(encrypt_method_name, message, context: self) send("#{encrypted_attribute}=", ciphertext) super(original_message) end define_method(name) do message = super() unless message ciphertext = send(encrypted_attribute) - message = - if ciphertext.nil? || (ciphertext == "" && !options[:padding]) - ciphertext - else - ciphertext = Base64.decode64(ciphertext) if encode - table = self.class.respond_to?(:table_name) ? self.class.table_name : self.class.collection_name.to_s - Lockbox::Utils.build_box(self, options, table, encrypted_attribute).decrypt(ciphertext) + message = self.class.send(decrypt_method_name, ciphertext, context: self) + + if activerecord + # set previous attribute on first decrypt + if @attributes[name.to_s] + @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message) end - unless message.nil? - case options[:type] - when :boolean - message = message == "t" - when :date - message = ActiveRecord::Type::Date.new.deserialize(message) - when :datetime - message = ActiveRecord::Type::DateTime.new.deserialize(message) - when :time - message = ActiveRecord::Type::Time.new.deserialize(message) - when :integer - message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first) - when :float - message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first) - when :string - message.force_encoding(Encoding::UTF_8) - when :binary - # do nothing - # decrypt returns binary string + # cache + if respond_to?(:_write_attribute, true) + _write_attribute(name, message) if !@attributes.frozen? else - type = (self.class.try(:attribute_types) || {})[name.to_s] - if type && type.is_a?(ActiveRecord::Type::Serialized) - message = type.deserialize(message) - else - # default to string if not serialized - message.force_encoding(Encoding::UTF_8) - end + raw_write_attribute(name, message) if !@attributes.frozen? end - end - - # set previous attribute on first decrypt - @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message) if @attributes[name.to_s] - - # cache - if respond_to?(:_write_attribute, true) - _write_attribute(name, message) - elsif respond_to?(:raw_write_attribute) - raw_write_attribute(name, message) else instance_variable_set("@#{name}", message) end end message end # for fixtures - define_singleton_method class_method_name do |message, **opts| - table = respond_to?(:table_name) ? table_name : collection_name.to_s + define_singleton_method encrypt_method_name do |message, **opts| + table = activerecord ? table_name : collection_name.to_s unless message.nil? case options[:type] when :boolean message = ActiveRecord::Type::Boolean.new.serialize(message) @@ -300,21 +301,64 @@ when :string, :binary # do nothing # encrypt will convert to binary else type = (try(:attribute_types) || {})[name.to_s] - if type && type.is_a?(ActiveRecord::Type::Serialized) - message = type.serialize(message) - end + message = type.serialize(message) if type end end if message.nil? || (message == "" && !options[:padding]) message else ciphertext = Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message) - ciphertext = Base64.strict_encode64(ciphertext) if encode + ciphertext = Base64.strict_encode64(ciphertext) if options[:encode] ciphertext + end + end + + define_singleton_method decrypt_method_name do |ciphertext, **opts| + message = + if ciphertext.nil? || (ciphertext == "" && !options[:padding]) + ciphertext + else + ciphertext = Base64.decode64(ciphertext) if options[:encode] + table = activerecord ? table_name : collection_name.to_s + Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).decrypt(ciphertext) + end + + unless message.nil? + case options[:type] + when :boolean + message = message == "t" + when :date + message = ActiveRecord::Type::Date.new.deserialize(message) + when :datetime + message = ActiveRecord::Type::DateTime.new.deserialize(message) + when :time + message = ActiveRecord::Type::Time.new.deserialize(message) + when :integer + message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first) + when :float + message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first) + when :string + message.force_encoding(Encoding::UTF_8) + when :binary + # do nothing + # decrypt returns binary string + else + type = (try(:attribute_types) || {})[name.to_s] + message = type.deserialize(message) if type + message.force_encoding(Encoding::UTF_8) if !type || type.is_a?(ActiveModel::Type::String) + end + end + + message + end + + if options[:migrating] + before_validation do + send("#{name}=", send(original_name)) if send("#{original_name}_changed?") end end end end end