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.
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)
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
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
Aws.config[:credentials] = Aws::Credentials.new(
ENV['AMAZON_ACCESS_KEY'],
ENV['AMAZON_SECRET_KEY']
)
gem 'attr_encrypted' # 3.1.0
gem 'aws-sdk-kms'
gem 'kms_encrypted'
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