Search code examples
ruby-on-railsrubyamazon-kmsattr-encrypted

must specify an iv error using attr_encrypted


I'm trying to add an encrypted jsonb field to my user model in rails. I'm getting an error when attempting to read or set a value.

error

irb(main):002:0> User.last.q2_email_address = "[email protected]"
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1  [["LIMIT", 1]]
  Encrypt Data Key (190.1ms)  Context: {:model_name=>"User", :model_id=>11}
/Users/antarr/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/encryptor-3.0.0/lib/encryptor.rb:61:in `crypt': must specify an iv (ArgumentError)

user.rb

    has_kms_key eager_encrypt: :fetch_id
    attr_encrypted :settings, key: :kms_key, mashall: true
  
    store :settings,
          accessors: [:address1, :address2, :city, :customer_id, :customer_name, :customer_primary_cif, :social_security_number,
                      :email_address, :first_name, :group_desc, :group_id, :home_phone, :aba, :hq_session_id, :language, :last_name,
                      :login_name, :middle_name, :mobile_phone, :postal_code, :ssn, :state, :user_logon_id, :user_id, :user_primary_cif,
                      :work_phone, :ip_address, :token], coder: JSON, prefix: :q2

migration

  def up
    add_column :users, :encrypted_kms_key, :text
    add_column :users, :encrypted_settings, :jsonb, null: false, default: '{}'
    add_column :users, :encrypted_settings_iv, :string
    add_index :users, :encrypted_settings, using: :gin
  end

config/initializer/aws.rb

Aws.config[:credentials] = Aws::Credentials.new(
  ENV['AMAZON_ACCESS_KEY'],
  ENV['AMAZON_SECRET_KEY']
)

Gemfile

gem 'attr_encrypted' # 3.1.0
gem 'aws-sdk-kms'
gem 'kms_encrypted'

Solution

  • there're 2 method encrypt in the gem attr_encrypted, one is class method and one is instance method, the instance method will automatically generate random iv, meanwhile the class method will not, it uses the iv you setup.

    when you call User.last.q2_email_address = "[email protected]", this method will call the class method encrypt, the instance method will not be called, so that if you don't setup iv, the error must specify an iv (ArgumentError) will be raised.

    there's 2 ways to fix, the first one is setup the iv

    attr_encrypted :settings, key: :kms_key, mashall: true, iv: SecureRandom.random_bytes(12)
    

    unfortunately, another error will be raised (relate to iv_len) and i'm still not figure out the root cause and fix it.

    the second way: copy this method into the model so that the instance method encrypt will be called, a random iv will be generated, and it works.

    however, in case of store attributes, it does not save encrypted attribute, for example

    user = User.last
    user.settings = {....}
    user.save! # OK
    
    user.q2_email_address = "[email protected]" 
    user.settings # changed, but not re-encrypt `encrypted_settings`
    user.save! # changed settings will not be saved
    

    so i came up with an idea that we can create a module support store attributes as below

    # model/concern/store_encrypted.rb
    module StoreEncrypted
      def self.extended(base)
        base.class_eval do
         # initialize store attributes values with default `{}`
         # so that a new object or existed object that miss iv
         # will generate random iv
         def initialize(*args)
          super(*args)
          @@store_attr_encryptes.each do |attribute|
            instance_variable_get("@#{attribute}") || send("#{attribute}=", {})
          end
         end
    
          alias old_save! save!
          def save!(**options, &block)
            # re-set attribute (encrypt again)
            @@store_attr_encryptes&.each do |attribute|
              send("#{attribute}=", send("#{attribute}"))
            end
            old_save!(**options, &block)
          end
        end
      end
    
      def store_encrypted(attribute, options={})
        @@store_attr_encryptes ||= []
        @@store_attr_encryptes << attribute
        attr_encrypted attribute, key: options[:key], marshal: options[:marshal]
        store attribute, accessors: options[:accessors], coder: options[:coder], 
                          prefix: options[:prefix], suffix: options[:suffix]
        # copy
        define_method("#{attribute}=") do |value|
          send("encrypted_#{attribute}=", encrypt(attribute, value))
          instance_variable_set("@#{attribute}", value)
        end
      end
    end
    
    # user.rb
    class User < ActiveRecord::Base
      extend StoreEncrypted
      has_kms_key eager_encrypt: :fetch_id
      store_encrypted :settings, key: :kms_key, mashall: true,
                      accessors: [...], coder: JSON, prefix: :q2
    end
    
    #
    user = User.last
    user.q2_email_address = "[email protected]" 
    user.save! # it'll re-encrypt settings