From 1e3485dc4014a546ac94210e258d7ea96eaa5c9b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:50:30 +0200 Subject: [PATCH] feat(orm): add Prism and Orm --- lib/prism/orm/secure_password.rb | 116 +++++++++++++++++++++++++++++++ lib/prism/orm/secure_token.rb | 38 ++++++++++ 2 files changed, 154 insertions(+) create mode 100644 lib/prism/orm/secure_password.rb create mode 100644 lib/prism/orm/secure_token.rb diff --git a/lib/prism/orm/secure_password.rb b/lib/prism/orm/secure_password.rb new file mode 100644 index 0000000..6354b06 --- /dev/null +++ b/lib/prism/orm/secure_password.rb @@ -0,0 +1,116 @@ +require 'openssl' +require 'securerandom' + +module Prism + module Orm + # `has_secure_password`, Rails-style, without the bcrypt dependency: the + # digest is PBKDF2-HMAC-SHA256 (stdlib OpenSSL, no gem needed) stored as + # "iterations$salt$hash" so the cost factor can grow later without + # invalidating digests written by an older version. + module SecurePassword + Iterations = 20_000 + KeyLength = 32 + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def secure_password_attributes + @secure_password_attributes ||= [] + end + + # Defines `password`/`password=`/`password_confirmation=` (virtual, + # never persisted) plus `authenticate_password` on top of a real + # `password_digest` column, and wires presence/confirmation checks + # into the model's own validation pipeline. + def has_secure_password(attribute = :password, validations: true) + attribute = attribute.to_sym + secure_password_attributes << attribute + digest_column = "#{attribute}_digest" + + define_secure_password_reader(attribute) + define_secure_password_writer(attribute, digest_column) + define_secure_password_confirmation_writer(attribute) + define_secure_password_authenticator(attribute, digest_column) + hide_from_json(digest_column) if respond_to?(:hide_from_json) + + validate { validate_secure_password(attribute, digest_column) } if validations + end + + private + + def define_secure_password_reader(attribute) + define_method(attribute) { secure_password_transient[attribute] } + end + + def define_secure_password_writer(attribute, digest_column) + define_method("#{attribute}=") do |unencrypted| + secure_password_transient[attribute] = unencrypted + next if unencrypted.nil? || unencrypted.empty? + self[digest_column] = SecurePassword.digest(unencrypted) + end + end + + def define_secure_password_confirmation_writer(attribute) + define_method("#{attribute}_confirmation=") do |unencrypted| + secure_password_transient["#{attribute}_confirmation"] = unencrypted + end + end + + def define_secure_password_authenticator(attribute, digest_column) + method_name = attribute == :password ? :authenticate : "authenticate_#{attribute}" + define_method(method_name) do |unencrypted| + digest = self[digest_column] + return false if digest.nil? || digest.empty? + SecurePassword.verify(digest, unencrypted) ? self : false + end + end + end + + # PBKDF2-HMAC-SHA256 with a fresh random salt per password. + def self.digest(unencrypted_password) + salt = SecureRandom.hex(16) + "#{Iterations}$#{salt}$#{hexdigest(unencrypted_password, salt, Iterations)}" + end + + def self.verify(digest, unencrypted_password) + iterations, salt, expected = digest.split('$', 3) + return false unless iterations && salt && expected + candidate = hexdigest(unencrypted_password, salt, iterations.to_i) + secure_compare(candidate, expected) + end + + def self.hexdigest(password, salt, iterations) + OpenSSL::PKCS5.pbkdf2_hmac(password.to_s, salt, iterations, KeyLength, OpenSSL::Digest.new('SHA256')).unpack1('H*') + end + + # Constant-time comparison so a timing attack cannot narrow down a + # correct digest one byte at a time. + def self.secure_compare(a, b) + return false unless a.bytesize == b.bytesize + left = a.unpack('C*') + result = 0 + b.each_byte.with_index { |byte, index| result |= byte ^ left[index] } + result.zero? + end + + private + + def secure_password_transient + @secure_password_transient ||= {} + end + + def validate_secure_password(attribute, digest_column) + transient = secure_password_transient + confirmation = transient["#{attribute}_confirmation"] + if confirmation && transient[attribute] != confirmation + errors.add(attribute, "doesn't match confirmation") + end + + current_digest = self[digest_column] + errors.add(attribute, 'must be present') if new_record? && (current_digest.nil? || current_digest.empty?) + end + end + end +end diff --git a/lib/prism/orm/secure_token.rb b/lib/prism/orm/secure_token.rb new file mode 100644 index 0000000..1323961 --- /dev/null +++ b/lib/prism/orm/secure_token.rb @@ -0,0 +1,38 @@ +require 'securerandom' + +module Prism + module Orm + # `has_secure_token`, Rails-style: assigns a random URL-safe token to a + # column right before the record is first created (unless one was + # already assigned by hand), plus a `regenerate_!` that persists a + # freshly generated replacement. No collision retry - like Rails, a + # 24-byte token is treated as unique enough on its own; pair the column + # with `validates ..., uniqueness: true` if a guarantee is required. + module SecureToken + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def secure_token_attributes + @secure_token_attributes ||= [] + end + + def has_secure_token(attribute = :token, length: 24) + attribute = attribute.to_sym + secure_token_attributes << attribute + + before_create { self[attribute] = SecureToken.generate(length) if self[attribute].to_s.empty? } + + define_method("regenerate_#{attribute}!") do + update!(attribute => SecureToken.generate(length)) + end + end + end + + def self.generate(length = 24) + SecureRandom.urlsafe_base64(length, padding: false) + end + end + end +end