lib/lockbox/model.rb in lockbox-0.2.0 vs lib/lockbox/model.rb in lockbox-0.2.1

- old
+ new

@@ -29,11 +29,41 @@ @lockbox_attachments[name] = options end end end - def encrypts(*attributes, **options) + def encrypts(*attributes, encode: true, **options) + # support objects + # case options[:type] + # when Date + # options[:type] = :date + # when Time + # options[:type] = :datetime + # when JSON + # options[:type] = :json + # when Hash + # options[:type] = :hash + # when String + # options[:type] = :string + # when Integer + # options[:type] = :integer + # when Float + # options[:type] = :float + # end + + raise ArgumentError, "Unknown type: #{options[:type]}" unless [nil, :string, :boolean, :date, :datetime, :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 + attributes.each do |name| # add default options encrypted_attribute = "#{name}_ciphertext" options = options.dup @@ -69,11 +99,11 @@ parent_attributes.merge(@lockbox_attributes || {}) end end raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name] - @lockbox_attributes[original_name] = options + @lockbox_attributes[original_name] = options.merge(encode: encode) if @lockbox_attributes.size == 1 def serializable_hash(options = nil) options = options.try(:dup) || {} options[:except] = Array(options[:except]) @@ -87,61 +117,142 @@ serializable_hash.map do |k,v| "#{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 changes.include?(attribute) && self.class.attribute_types[attribute].is_a?(ActiveRecord::Type::Serialized) + send("#{attribute}=", send(attribute)) + end + end + end end - attribute name, :string + serialize name, JSON if options[:type] == :json + serialize name, Hash if options[:type] == :hash + attribute name, attribute_type + define_method("#{name}=") do |message| + original_message = message + + unless message.nil? + case options[:type] + when :boolean + message = ActiveRecord::Type::Boolean.new.serialize(message) + message = nil if message == "" # for Active Record < 5.2 + message = message ? "t" : "f" unless message.nil? + when :date + message = ActiveRecord::Type::Date.new.serialize(message) + # strftime should be more stable than to_s(:db) + message = message.strftime("%Y-%m-%d") unless message.nil? + when :datetime + message = ActiveRecord::Type::DateTime.new.serialize(message) + message = nil unless message.respond_to?(:iso8601) # for Active Record < 5.2 + message = message.iso8601(9) unless message.nil? + when :integer + message = ActiveRecord::Type::Integer.new(limit: 8).serialize(message) + message = 0 if message.nil? + # signed 64-bit integer, big endian + message = [message].pack("q>") + when :float + message = ActiveRecord::Type::Float.new.serialize(message) + # double precision, big endian + message = [message].pack("G") unless message.nil? + when :string, :binary + # do nothing + # encrypt will convert to binary + else + type = self.class.attribute_types[name.to_s] + if type.is_a?(ActiveRecord::Type::Serialized) + message = type.serialize(message) + end + end + end + # decrypt first for dirty tracking # don't raise error if can't decrypt previous begin send(name) rescue Lockbox::DecryptionError nil end ciphertext = - if message.nil? || message == "" + if message.nil? || (message == "" && !options[:padding]) message else self.class.send(class_method_name, message, context: self) end send("#{encrypted_attribute}=", ciphertext) - super(message) + super(original_message) end define_method(name) do message = super() + unless message ciphertext = send(encrypted_attribute) message = - if ciphertext.nil? || ciphertext == "" + if ciphertext.nil? || (ciphertext == "" && !options[:padding]) ciphertext else - decoded = Base64.decode64(ciphertext) - Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(decoded) + ciphertext = Base64.decode64(ciphertext) if encode + Lockbox::Utils.build_box(self, options, self.class.table_name, 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 :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 = message.encode(Encoding::UTF_8) + when :binary + # do nothing + # decrypt returns binary string + else + type = self.class.attribute_types[name.to_s] + if type.is_a?(ActiveRecord::Type::Serialized) + message = type.deserialize(message) + end + end + end + # set previous attribute on first decrypt @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message) # cache if respond_to?(:_write_attribute, true) _write_attribute(name, message) else raw_write_attribute(name, message) end end + message end # for fixtures define_singleton_method class_method_name do |message, **opts| - Base64.strict_encode64(Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message)) + ciphertext = Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message) + ciphertext = Base64.strict_encode64(ciphertext) if encode + ciphertext end end end end end