From 2525025d096d38f54f1aed747d2dd66f5d2a0c4d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 22:43:37 +0200 Subject: [PATCH] feat(orm): implement Prism and Orm --- lib/prism/orm/encryption.rb | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 lib/prism/orm/encryption.rb diff --git a/lib/prism/orm/encryption.rb b/lib/prism/orm/encryption.rb new file mode 100644 index 0000000..b0127b4 --- /dev/null +++ b/lib/prism/orm/encryption.rb @@ -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 +