feat(orm): implement Prism and Orm
This commit is contained in:
1 parent
490930c944
commit
2525025d09
1 file changed
+167
@@ -0,0 +1,167 @@
|
||||
require 'securerandom'
|
||||
|
||||
module Prism
|
||||
module Orm
|
||||
# ActiveRecord::Encryption-lite: transparent attribute encryption at
|
||||
# rest, riding on the same AES-256-GCM primitive as MessageEncryptor
|
||||
# (Prism::Security) rather than a second crypto implementation. Every
|
||||
# encrypted column gets its OWN derived key (HKDF-style salt namespaced
|
||||
# by table+attribute) from one shared application secret, so leaking the
|
||||
# ciphertext and key of one column never helps decrypt another, and the
|
||||
# same secret can safely also back cookies/CSRF elsewhere in the app -
|
||||
# `MessageEncryptor.derive_key` is what makes that separation free.
|
||||
#
|
||||
# `encrypts :ssn` stores ciphertext in the underlying column and
|
||||
# decrypts it back to plaintext on read. Non-deterministic by default
|
||||
# (a random IV per write - the same plaintext saved twice looks
|
||||
# completely different on disk, matching Rails' default and closing off
|
||||
# ciphertext-equality attacks). `deterministic: true` derives the IV
|
||||
# from HMAC(key, plaintext) instead, trading that guarantee away on
|
||||
# purpose so `Model.where(attribute: plaintext)` can still match - the
|
||||
# exact tradeoff ActiveRecord::Encryption documents for the same flag.
|
||||
module Encryption
|
||||
class Error < StandardError; end
|
||||
|
||||
CIPHER_NAME = 'aes-256-gcm'.freeze
|
||||
IV_LENGTH = 12
|
||||
|
||||
class << self
|
||||
# One shared secret for the whole app (Application#enable_attribute_
|
||||
# encryption sets this at boot, same spot as enable_sessions). A
|
||||
# single `encrypts ..., key: '...'` call can still override it per
|
||||
# attribute for a column that must rotate independently of the rest.
|
||||
attr_writer :primary_key
|
||||
|
||||
def primary_key
|
||||
@primary_key
|
||||
end
|
||||
|
||||
# Key rotation: retiring the shared application secret (a leak, a
|
||||
# scheduled rotation policy) does not have to mean every existing
|
||||
# ciphertext on disk becomes unreadable overnight. Old secrets keep
|
||||
# decrypting here (a column's writer always uses `primary_key`, so
|
||||
# any row that gets explicitly re-saved is re-encrypted under the
|
||||
# new one for free - there is no separate "encrypt" half to update).
|
||||
# A single `encrypts ..., previous_keys: [...]` call can override
|
||||
# this list per attribute instead, for a column whose OWN `key:`
|
||||
# rotates on a different schedule than the app-wide secret.
|
||||
attr_writer :previous_keys
|
||||
|
||||
def previous_keys
|
||||
@previous_keys ||= []
|
||||
end
|
||||
|
||||
def encrypt(key, value, deterministic:)
|
||||
return nil if value.nil?
|
||||
plaintext = value.to_s
|
||||
iv = deterministic ? OpenSSL::HMAC.digest('SHA256', key, plaintext)[0, IV_LENGTH] : SecureRandom.random_bytes(IV_LENGTH)
|
||||
cipher = new_cipher(:encrypt, key, iv)
|
||||
ciphertext = cipher.update(plaintext) + cipher.final
|
||||
[encode(ciphertext), encode(iv), encode(cipher.auth_tag)].join('--')
|
||||
end
|
||||
|
||||
def decrypt(key, message)
|
||||
return nil if message.nil?
|
||||
ciphertext, iv, auth_tag = message.to_s.split('--', 3)
|
||||
raise Error, 'invalid encrypted value' if [ciphertext, iv, auth_tag].any?(&:nil?)
|
||||
|
||||
cipher = new_cipher(:decrypt, key, decode(iv))
|
||||
cipher.auth_tag = decode(auth_tag)
|
||||
cipher.update(decode(ciphertext)) + cipher.final
|
||||
rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError
|
||||
raise Error, 'invalid encrypted value or wrong key'
|
||||
end
|
||||
|
||||
# Tries the current key first (the common case, and the only one
|
||||
# that should ever be a cache hit); falls through the previous keys
|
||||
# in order only once that fails, so a value already re-encrypted
|
||||
# under the new key never pays for trying stale ones first. Raises
|
||||
# the ORIGINAL error from the current key once every key - current
|
||||
# and previous - has failed, not the last previous key's error,
|
||||
# since "wrong key" against the current key is the one message an
|
||||
# operator actually needs to see.
|
||||
def decrypt_with_rotation(key, previous_keys, message)
|
||||
return nil if message.nil?
|
||||
decrypt(key, message)
|
||||
rescue Error => original_error
|
||||
previous_keys.each do |previous_key|
|
||||
begin
|
||||
return decrypt(previous_key, message)
|
||||
rescue Error
|
||||
next
|
||||
end
|
||||
end
|
||||
raise original_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_cipher(mode, key, iv)
|
||||
cipher = OpenSSL::Cipher.new(CIPHER_NAME)
|
||||
cipher.public_send(mode)
|
||||
cipher.key = key
|
||||
cipher.iv = iv
|
||||
cipher.auth_data = ''
|
||||
cipher
|
||||
end
|
||||
|
||||
def encode(bytes)
|
||||
[bytes].pack('m0')
|
||||
end
|
||||
|
||||
def decode(text)
|
||||
text.unpack1('m0')
|
||||
end
|
||||
end
|
||||
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Superclass-plus-own fallback, same shape as Callbacks#callbacks_for
|
||||
# and Serialization#json_hidden_attributes - a plain `@ivar ||= {}`
|
||||
# per class would drop a base class's encrypted columns in every
|
||||
# subclass instead of inheriting them.
|
||||
def encrypted_attributes
|
||||
inherited = superclass.respond_to?(:encrypted_attributes) ? superclass.encrypted_attributes : {}
|
||||
inherited.merge(own_encrypted_attributes)
|
||||
end
|
||||
|
||||
def own_encrypted_attributes
|
||||
@own_encrypted_attributes ||= {}
|
||||
end
|
||||
|
||||
def encrypts(attribute, deterministic: false, key: nil, previous_keys: nil)
|
||||
attribute = attribute.to_s
|
||||
own_encrypted_attributes[attribute] = { deterministic: deterministic, key: key, previous_keys: previous_keys }
|
||||
hide_from_json(attribute)
|
||||
|
||||
define_method(attribute) do
|
||||
Encryption.decrypt_with_rotation(self.class.encryption_key_for(attribute),
|
||||
self.class.encryption_previous_keys_for(attribute),
|
||||
self[attribute])
|
||||
end
|
||||
|
||||
# Non-deterministic encryption gives the same plaintext a fresh
|
||||
# random IV (hence different ciphertext bytes) every time it is
|
||||
# encrypted - so re-assigning the value a record already holds
|
||||
# would otherwise still overwrite @attributes with new ciphertext
|
||||
# and mark the attribute dirty for a change that never happened.
|
||||
# Comparing against the current plaintext first (public_send, not
|
||||
# self[], so it goes through this very reader and decrypts) makes
|
||||
# `model.attr = model.attr` a true no-op, matching plain columns.
|
||||
# A current value that fails to decrypt (corrupted on disk, or
|
||||
# mid-rotation past every known key) can't be compared at all -
|
||||
# rescue and fall through to a fresh write instead of blocking
|
||||
# every future assignment behind an unrelated read failure.
|
||||
define_method("#{attribute}=") do |value|
|
||||
begin
|
||||
return if value == public_send(attribute)
|
||||
rescue Encryption::Error
|
||||
nil
|
||||
end
|
||||
self[attribute] = Encryption.encrypt(self.class.encryption_key_for(attribute), value, deterministic: deterministic)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user