lib/rack/session/encryptor.rb
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2023, by Samuel Williams. # Copyright, 2022, by Philip Arndt. require 'base64' require 'openssl' require 'securerandom' require 'zlib' require 'rack/utils' module Rack module Session class Encryptor class Error < StandardError end class InvalidSignature < Error end class InvalidMessage < Error end # The secret String must be at least 64 bytes in size. The first 32 bytes # will be used for the encryption cipher key. The remainder will be used # for an HMAC key. # # Options may include: # * :serialize_json # Use JSON for message serialization instead of Marshal. This can be # viewed as a security enhancement. # * :pad_size # Pad encrypted message data, to a multiple of this many bytes # (default: 32). This can be between 2-4096 bytes, or +nil+ to disable # padding. # * :purpose # Limit messages to a specific purpose. This can be viewed as a # security enhancement to prevent message reuse from different contexts # if keys are reused. # # Cryptography and Output Format: # # urlsafe_encode64(version + random_data + IV + encrypted data + HMAC) # # Where: # * version - 1 byte and is currently always 0x01 # * random_data - 32 bytes used for generating the per-message secret # * IV - 16 bytes random initialization vector # * HMAC - 32 bytes HMAC-SHA-256 of all preceding data, plus the purpose # value def initialize(secret, opts = {}) raise ArgumentError, "secret must be a String" unless String === secret raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64 case opts[:pad_size] when nil # padding is disabled when Integer raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}" unless (2..4096).include? opts[:pad_size] else raise ArgumentError, "invalid pad_size: #{opts[:pad_size]}; must be Integer or nil" end @options = { serialize_json: false, pad_size: 32, purpose: nil }.update(opts) @hmac_secret = secret.dup.force_encoding('BINARY') @cipher_secret = @hmac_secret.slice!(0, 32) @hmac_secret.freeze @cipher_secret.freeze end def decrypt(base64_data) data = Base64.urlsafe_decode64(base64_data) signature = data.slice!(-32..-1) verify_authenticity! data, signature # The version is reserved for future _version = data.slice!(0, 1) message_secret = data.slice!(0, 32) cipher_iv = data.slice!(0, 16) cipher = new_cipher cipher.decrypt set_cipher_key(cipher, cipher_secret_from_message_secret(message_secret)) cipher.iv = cipher_iv data = cipher.update(data) << cipher.final deserialized_message data rescue ArgumentError raise InvalidSignature, 'Message invalid' end def encrypt(message) version = "\1" serialized_payload = serialize_payload(message) message_secret, cipher_secret = new_message_and_cipher_secret cipher = new_cipher cipher.encrypt set_cipher_key(cipher, cipher_secret) cipher_iv = cipher.random_iv encrypted_data = cipher.update(serialized_payload) << cipher.final data = String.new data << version data << message_secret data << cipher_iv data << encrypted_data data << compute_signature(data) Base64.urlsafe_encode64(data) end private def new_cipher OpenSSL::Cipher.new('aes-256-ctr') end def new_message_and_cipher_secret message_secret = SecureRandom.random_bytes(32) [message_secret, cipher_secret_from_message_secret(message_secret)] end def cipher_secret_from_message_secret(message_secret) OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @cipher_secret, message_secret) end def set_cipher_key(cipher, key) cipher.key = key end def serializer @serializer ||= @options[:serialize_json] ? JSON : Marshal end def compute_signature(data) signing_data = data signing_data += @options[:purpose] if @options[:purpose] OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @hmac_secret, signing_data) end def verify_authenticity!(data, signature) raise InvalidMessage, 'Message is invalid' if data.nil? || signature.nil? unless Rack::Utils.secure_compare(signature, compute_signature(data)) raise InvalidSignature, 'HMAC is invalid' end end # Returns a serialized payload of the message. If a :pad_size is supplied, # the message will be padded. The first 2 bytes of the returned string will # indicating the amount of padding. def serialize_payload(message) serialized_data = serializer.dump(message) return "#{[0].pack('v')}#{serialized_data}" if @options[:pad_size].nil? padding_bytes = @options[:pad_size] - (2 + serialized_data.size) % @options[:pad_size] padding_data = SecureRandom.random_bytes(padding_bytes) "#{[padding_bytes].pack('v')}#{padding_data}#{serialized_data}" end # Return the deserialized message. The first 2 bytes will be read as the # amount of padding. def deserialized_message(data) # Read the first 2 bytes as the padding_bytes size padding_bytes, = data.unpack('v') # Slice out the serialized_data and deserialize it serialized_data = data.slice(2 + padding_bytes, data.bytesize) serializer.load serialized_data end end end end