feat(security): add Prism, MessageEncryptor and MessageVerifier

This commit is contained in:
dualfroz committed 2026-09-08 21:23:03 +02:00
1 parent 948376736e
commit 590cd639aa
2 files changed
+163

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
module Prism
# Authenticated encryption (AES-256-GCM) for values that must not be
# readable by the client at all, such as encrypted cookies. Use
# MessageEncryptor.derive_key to turn an arbitrary application secret
# into a valid 32-byte key.
class MessageEncryptor
class InvalidMessage < StandardError; end
CIPHER_NAME = 'aes-256-gcm'.freeze
KEY_LENGTH = 32
def self.derive_key(secret, salt: 'prism-message-encryptor', length: KEY_LENGTH)
OpenSSL::PKCS5.pbkdf2_hmac(secret.to_s, salt.to_s, 2 ** 16, length, OpenSSL::Digest.new('SHA256'))
end
def initialize(key, serializer: JSON)
raise ArgumentError, "key must be #{KEY_LENGTH} bytes, got #{key.bytesize}" unless key.bytesize == KEY_LENGTH
@key = key
@serializer = serializer
end
def encrypt_and_sign(value)
cipher = new_cipher(:encrypt)
iv = cipher.random_iv
cipher.auth_data = ''
plaintext = @serializer.dump(value)
encrypted = cipher.update(plaintext) + cipher.final
[encode(encrypted), encode(iv), encode(cipher.auth_tag)].join('--')
end
def decrypt_and_verify(message)
encrypted, iv, auth_tag = message.to_s.split('--', 3)
raise InvalidMessage, 'invalid message' if [encrypted, iv, auth_tag].any?(&:nil?)
cipher = new_cipher(:decrypt)
cipher.iv = decode(iv)
cipher.auth_tag = decode(auth_tag)
cipher.auth_data = ''
decrypted = cipher.update(decode(encrypted)) + cipher.final
@serializer.load(decrypted)
rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError
raise InvalidMessage, 'invalid message'
end
private
def new_cipher(mode)
cipher = OpenSSL::Cipher.new(CIPHER_NAME)
cipher.public_send(mode)
cipher.key = @key
cipher
end
def encode(bytes)
[bytes].pack('m0')
end
def decode(text)
text.unpack1('m0')
end
end
end
+101
View File
@@ -0,0 +1,101 @@
module Prism
# Signs arbitrary serializable values with an HMAC so tampering can be
# detected on the way back in (query strings, cookies, hidden form
# fields). This does not hide the payload - see MessageEncryptor for that.
class MessageVerifier
class InvalidSignature < StandardError; end
def initialize(secret, digest: 'SHA256', serializer: JSON)
raise ArgumentError, 'secret must be present' if secret.nil? || secret.to_s.empty?
@secret = secret.to_s
@digest = digest
@serializer = serializer
end
# `expires_in`/`expires_at` and `purpose` mirror Rails' MessageVerifier:
# a link generated with `purpose: 'cover_download'` fails #verify for
# any other purpose (or none), and a link past its expiry fails #verify
# the same way a tampered signature would - both close over the exact
# same InvalidSignature the caller already has to handle, so a
# controller cannot accidentally forget to check them separately.
# Omitting both keeps the plain envelope-free format earlier callers
# (CookieJar, Csrf) already rely on - this is additive, not a format
# bump, so it never breaks a signed value already in flight.
#
# `options` is a plain trailing Hash, not real keyword arguments, on
# purpose: every existing caller in this codebase (and the tests below)
# calls `generate('user_id' => 1)` - a bareword Hash AS the value being
# signed, no braces. Ruby's parser treats that call-site shape as
# keyword syntax regardless of the callee's actual parameter names, so
# declaring real `expires_in:`/`purpose:` keywords here would make
# every one of those calls raise "wrong number of arguments (given 0,
# expected 1)" instead of signing the hash - proven by hand before this
# method existed in this shape.
def generate(value, options = {})
exp = options[:expires_at] || (options[:expires_in] ? Time.now.utc + options[:expires_in] : nil)
payload = wrap(value, exp, options[:purpose])
data = encode(@serializer.dump(payload))
"#{data}--#{digest_for(data)}"
end
def verify(signed, purpose: nil)
raise InvalidSignature, 'invalid message' unless valid_message?(signed)
data, = signed.split('--', 2)
unwrap(@serializer.load(decode(data)), purpose)
rescue ArgumentError, TypeError
raise InvalidSignature, 'invalid message'
end
def verified(signed, purpose: nil)
verify(signed, purpose: purpose)
rescue InvalidSignature
nil
end
def valid_message?(signed)
return false if signed.nil? || !signed.include?('--')
data, signature = signed.split('--', 2)
return false if data.nil? || data.empty? || signature.nil? || signature.empty?
secure_compare(signature, digest_for(data))
end
private
ENVELOPE_KEY = '_prism_envelope'
def wrap(value, exp, purpose)
return value if exp.nil? && purpose.nil?
{ ENVELOPE_KEY => true, 'value' => value, 'exp' => exp&.utc&.iso8601, 'pur' => purpose&.to_s }
end
def unwrap(payload, purpose)
unless payload.is_a?(Hash) && payload[ENVELOPE_KEY]
raise InvalidSignature, 'message requires a purpose' if purpose
return payload
end
raise InvalidSignature, 'message expired' if payload['exp'] && Time.parse(payload['exp']) <= Time.now.utc
raise InvalidSignature, 'purpose mismatch' unless payload['pur'] == purpose&.to_s
payload['value']
end
def digest_for(data)
OpenSSL::HMAC.hexdigest(@digest, @secret, data)
end
def encode(text)
[text].pack('m0')
end
def decode(text)
text.unpack1('m0')
end
def secure_compare(left, right)
return false unless left.bytesize == right.bytesize
left_bytes = left.unpack("C#{left.bytesize}")
difference = 0
right.each_byte.with_index { |byte, index| difference |= byte ^ left_bytes[index] }
difference.zero?
end
end
end