diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 704d1a5..45ef417 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,10 +21,6 @@ jobs: - macos ruby: - - "2.5" - - "2.6" - - "2.7" - - "3.0" - "3.1" - "3.2" - "3.3" diff --git a/config/sus.rb b/config/sus.rb new file mode 100644 index 0000000..94b7eb6 --- /dev/null +++ b/config/sus.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +TEST_PATTERN = "sus/**/*.rb" + +def test_paths + return Dir.glob(TEST_PATTERN, base: @root) +end diff --git a/gems.rb b/gems.rb index 09be0c1..95481ac 100644 --- a/gems.rb +++ b/gems.rb @@ -7,6 +7,8 @@ gemspec +gem "rake" + group :maintenance, optional: true do if RUBY_VERSION > "3.1" gem "bake" @@ -24,6 +26,12 @@ end group :test do + gem "sus" + gem "bake-test" gem "bake-test-external" + + gem "minitest", "~> 5.0" + gem "minitest-global_expectations" + gem "minitest-sprint" end diff --git a/lib/rack/session/abstract/id.rb b/lib/rack/session/abstract/id.rb index 52446a5..e2d533b 100644 --- a/lib/rack/session/abstract/id.rb +++ b/lib/rack/session/abstract/id.rb @@ -15,9 +15,7 @@ require_relative '../constants' module Rack - module Session - class SessionId ID_VERSION = 2 diff --git a/lib/rack/session/cookie.rb b/lib/rack/session/cookie.rb index 830a4e3..b84976e 100644 --- a/lib/rack/session/cookie.rb +++ b/lib/rack/session/cookie.rb @@ -156,26 +156,30 @@ def decode(str) attr_reader :coder, :encryptors - def initialize(app, options = {}) - # support both :secrets and :secret for backwards compatibility - secrets = [*(options[:secrets] || options[:secret])] + def initialize(app, coder: Marshal, serialize_json: false, key: nil, purpose: nil, secrets: [], secret: nil, **options) + # Support both :secrets and :secret for backwards compatibility: + if secret + secrets << secret + end + + # `serialize_json` is awefully specific... allow a general `coder` option: + if serialize_json + coder ||= JSON + end - encryptor_opts = { - purpose: options[:key], serialize_json: options[:serialize_json] - } + # Let's consider `key` to be legacy: + purpose ||= key - # For each secret, create an Encryptor. We have iterate this Array at - # decryption time to achieve key rotation. + # For each secret, create an Encryptor, to support key rotation: @encryptors = secrets.map do |secret| - Rack::Session::Encryptor.new secret, encryptor_opts + Rack::Session::Encryptor.new(secret, delegate: coder, purpose: purpose) end - # If a legacy HMAC secret is present, initialize those features. - # Fallback to :secret for backwards compatibility. - if options.has_key?(:legacy_hmac_secret) || options.has_key?(:secret) + # If a legacy HMAC secret is present, initialize those features: + if options.has_key?(:legacy_hmac_secret) || secret @legacy_hmac = options.fetch(:legacy_hmac, 'SHA1') - @legacy_hmac_secret = options[:legacy_hmac_secret] || options[:secret] + @legacy_hmac_secret = options[:legacy_hmac_secret] || secret @legacy_hmac_coder = options.fetch(:legacy_hmac_coder, Base64::Marshal.new) else @legacy_hmac = false @@ -216,7 +220,7 @@ def unpacked_cookie_data(request) session_data = nil # Try to decrypt the session data with our encryptors - encryptors.each do |encryptor| + @encryptors.each do |encryptor| begin session_data = encryptor.decrypt(cookie_data) break @@ -290,10 +294,10 @@ def legacy_generate_hmac(data) end def encode_session_data(session) - if encryptors.empty? + if @encryptors.empty? coder.encode(session) else - encryptors.first.encrypt(session) + @encryptors.first.encrypt(session) end end diff --git a/lib/rack/session/encryptor.rb b/lib/rack/session/encryptor.rb index 851245b..641c34b 100644 --- a/lib/rack/session/encryptor.rb +++ b/lib/rack/session/encryptor.rb @@ -4,411 +4,34 @@ # Copyright, 2022-2023, by Samuel Williams. # Copyright, 2022, by Philip Arndt. -require 'base64' -require 'json' -require 'openssl' -require 'securerandom' - -require 'rack/utils' +require_relative "payload/encrypted" +require_relative "payload/padded" module Rack module Session class Encryptor - class Error < StandardError - end - - class InvalidSignature < Error - end - - class InvalidMessage < Error - end - - module Serializable - private - - # 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.force_encoding(Encoding::BINARY)}" 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.force_encoding(Encoding::BINARY)}" - 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 - - def serializer - @serializer ||= @options[:serialize_json] ? JSON : Marshal - end - end - - class V1 - include Serializable - - # 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 with value 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 secret.is_a?(String) - 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(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) - - version = data.slice!(0, 1) - raise InvalidMessage, 'wrong version' unless version == "\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.new('SHA256'), @cipher_secret, message_secret) - end + def initialize(secrets, delegate: Marshal, pad_size: nil, **options) + @delegate = Payload::Encrypted.new(delegate, secrets, **options) - def set_cipher_key(cipher, key) - cipher.key = key - end - - def compute_signature(data) - signing_data = data - signing_data += @options[:purpose] if @options[:purpose] - - OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @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 + if pad_size + @delegate = Payload::Padded.new(@delegate, pad_size) end end - class V2 - include Serializable - - # The secret String must be at least 32 bytes in size. - # - # Options may include: - # * :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: - # - # strict_encode64(version + salt + IV + authentication tag + ciphertext) - # - # Where: - # * version - 1 byte with value 0x02 - # * salt - 32 bytes used for generating the per-message secret - # * IV - 12 bytes random initialization vector - # * authentication tag - 16 bytes authentication tag generated by the GCM mode, covering version and salt - # - # Considerations about V2: - # - # 1) It uses non URL-safe Base64 encoding as it's faster than its - # URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is - # roughly equivalent to - # - # Base64.strict_encode64(data).tr("-_", "+/") - # - # - and cookie values don't need to be URL-safe. - def initialize(secret, opts = {}) - raise ArgumentError, 'secret must be a String' unless secret.is_a?(String) - - unless secret.bytesize >= 32 - raise ArgumentError, "invalid secret: it's #{secret.bytesize}-byte long, must be >=32" - end - - 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) - - @cipher_secret = secret.dup.force_encoding(Encoding::BINARY).slice!(0, 32) - @cipher_secret.freeze - end - - def decrypt(base64_data) - data = Base64.strict_decode64(base64_data) - if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :) - raise InvalidMessage, 'invalid message' - end - - version = data[0] - raise InvalidMessage, 'invalid message' unless version == "\2" - - ciphertext = data.slice!(61..-1) - auth_tag = data.slice!(45, 16) - cipher_iv = data.slice!(33, 12) - - cipher = new_cipher - cipher.decrypt - salt = data.slice(1, 32) - set_cipher_key(cipher, message_secret_from_salt(salt)) - cipher.iv = cipher_iv - cipher.auth_tag = auth_tag - cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data - - plaintext = cipher.update(ciphertext) << cipher.final - - deserialized_message plaintext - rescue ArgumentError, OpenSSL::Cipher::CipherError - raise InvalidSignature, 'invalid message' - end - - def encrypt(message) - version = "\2" - - serialized_payload = serialize_payload(message) - - cipher = new_cipher - cipher.encrypt - salt, message_secret = new_salt_and_message_secret - set_cipher_key(cipher, message_secret) - cipher.iv_len = 12 - cipher_iv = cipher.random_iv - - data = String.new - data << version - data << salt - - cipher.auth_data = (purpose = @options[:purpose]) ? data + purpose : data - encrypted_data = cipher.update(serialized_payload) << cipher.final - - data << cipher_iv - data << auth_tag_from(cipher) - data << encrypted_data - - Base64.strict_encode64(data) - end - - private - - def new_cipher - OpenSSL::Cipher.new('aes-256-gcm') - end - - def new_salt_and_message_secret - salt = SecureRandom.random_bytes(32) - - [salt, message_secret_from_salt(salt)] - end - - def message_secret_from_salt(salt) - OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, salt) - end - - def set_cipher_key(cipher, key) - cipher.key = key - end - - if RUBY_ENGINE == 'jruby' - # JRuby's OpenSSL implementation doesn't currently support passing - # an argument to #auth_tag. Here we work around that. - def auth_tag_from(cipher) - tag = cipher.auth_tag - raise Error, 'the auth tag must be 16 bytes long' if tag.bytesize != 16 - - tag - end - else - def auth_tag_from(cipher) - cipher.auth_tag(16) - end - end - end - - def initialize(secret, opts = {}) - opts = opts.dup - - @mode = opts.delete(:mode)&.to_sym || :guess_version - case @mode - when :v1 - @v1 = V1.new(secret, opts) - when :v2 - @v2 = V2.new(secret, opts) - else - @v1 = V1.new(secret, opts) - @v2 = V2.new(secret, opts) - end + def load(data) + @delegate.load(data) end - def decrypt(base64_data) - decryptor = - case @mode - when :v2 - v2 - when :v1 - v1 - else - guess_decryptor(base64_data) - end - - decryptor.decrypt(base64_data) + def decrypt(data) + self.load(data) end - def encrypt(message) - encryptor = - case @mode - when :v1 - v1 - else - v2 - end - - encryptor.encrypt(message) + def dump(value) + @delegate.dump(value) end - private - - attr_reader :v1, :v2 - - def guess_decryptor(base64_data) - raise InvalidMessage, 'invalid message' if base64_data.nil? || base64_data.bytesize < 4 - - first_encoded_4_bytes = base64_data.slice(0, 4) - # Transform the 4 bytes into non-URL-safe base64-encoded data. Nothing - # happens if the data is already non-URL-safe base64. - first_encoded_4_bytes.tr!('-_', '+/') - first_decoded_3_bytes = Base64.strict_decode64(first_encoded_4_bytes) - - version = first_decoded_3_bytes[0] - case version - when "\2" - v2 - when "\1" - v1 - else - raise InvalidMessage, 'invalid message' - end - rescue ArgumentError - raise InvalidMessage, 'invalid message' + def encrypt(value) + self.dump(value) end end end diff --git a/lib/rack/session/error.rb b/lib/rack/session/error.rb new file mode 100644 index 0000000..e2bcb47 --- /dev/null +++ b/lib/rack/session/error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +module Rack + module Session + class Error < StandardError + end + + class InvalidSignature < Error + end + + class InvalidMessage < Error + end + end +end diff --git a/lib/rack/session/payload/compressed.rb b/lib/rack/session/payload/compressed.rb new file mode 100644 index 0000000..11ea516 --- /dev/null +++ b/lib/rack/session/payload/compressed.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "wrapper" + +require "zlib" + +module Rack + module Session + module Payload + class Compressed + def initialize(delegate) + @delegate = delegate + end + + def load(data) + @delegate.load(Zlib.inflate(data)) + end + + def dump(value) + Zlib.deflate(@delegate.dump(value)) + end + end + end + end +end diff --git a/lib/rack/session/payload/encrypted.rb b/lib/rack/session/payload/encrypted.rb new file mode 100644 index 0000000..7f8890c --- /dev/null +++ b/lib/rack/session/payload/encrypted.rb @@ -0,0 +1,34 @@ +require_relative "encrypted_v1" +require_relative "encrypted_v2" + +module Rack + module Session + module Payload + class Encrypted < Wrapper + def initialize(delegate, secret, **options) + @versioned = { + "\1" => EncryptedV1.new(delegate, secret, **options), + "\2" => EncryptedV2.new(delegate, secret, **options) + } + + @default = @versioned["\2"] + end + + def dump(value) + @default.dump(value) + end + + def load(data) + version_data = data.slice(0, 4) + + # Transform the 4 bytes into non-URL-safe base64-encoded data. Nothing + # happens if the data is already non-URL-safe base64. + version_data.tr!('-_', '+/') + version = Base64.strict_decode64(version_data) + + @versioned.fetch(version, @default).load(data) + end + end + end + end +end diff --git a/lib/rack/session/payload/encrypted_v1.rb b/lib/rack/session/payload/encrypted_v1.rb new file mode 100644 index 0000000..147ae77 --- /dev/null +++ b/lib/rack/session/payload/encrypted_v1.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "wrapper" +require_relative "../error" + +require "rack/utils" + +module Rack + module Session + module Payload + # Uses `AES-256-CTR` for encryption, combined with `HMAC` (Hash-based Message Authentication Code) for data *integrity/authentication*. The encryption and integrity mechanisms are separate, requiring both components to work together correctly. + # + # This is considered a legacy encryption scheme and should not be used for new applications. It is recommended to use `EncryptedV2` instead (the default for new sessions). + class EncryptedV1 < Wrapper + # 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 with value 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(delegate, secret, purpose: nil, **options) + super(delegate) + + raise ArgumentError, 'secret must be a String' unless secret.is_a?(String) + raise ArgumentError, "invalid secret: #{secret.bytesize}, must be >=64" unless secret.bytesize >= 64 + + @purpose = purpose + + @hmac_secret = secret.dup.force_encoding(Encoding::BINARY) + @cipher_secret = @hmac_secret.slice!(0, 32) + + @hmac_secret.freeze + @cipher_secret.freeze + end + + def load(data) + super(decrypt(data)) + end + + def dump(value) + encrypt(super(value)) + end + + private + + def decrypt(data) + signature = data.slice!(-32..-1) + verify_authenticity!(data, signature) + + version = data.slice!(0, 1) + raise InvalidMessage, 'wrong version' unless version == "\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(data) + 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) + end + + 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.new('SHA256'), @cipher_secret, message_secret) + end + + def set_cipher_key(cipher, key) + cipher.key = key + end + + def compute_signature(data) + signing_data = data + signing_data += @options[:purpose] if @options[:purpose] + + OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @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 + end + end + end +end diff --git a/lib/rack/session/payload/encrypted_v2.rb b/lib/rack/session/payload/encrypted_v2.rb new file mode 100644 index 0000000..f3549f6 --- /dev/null +++ b/lib/rack/session/payload/encrypted_v2.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "wrapper" +require_relative "../error" + +module Rack + module Session + module Payload + # Uses `AES-256-GCM`` for encryption. This mode provides an integrated approach to *confidentiality* and *integrity/authentication*, producing a single output that includes both the ciphertext and an authentication tag. + class EncryptedV2 < Wrapper + # The secret String must be at least 32 bytes in size. + # + # Options may include: + # * :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: + # + # strict_encode64(version + salt + IV + authentication tag + ciphertext) + # + # Where: + # * version - 1 byte with value 0x02 + # * salt - 32 bytes used for generating the per-message secret + # * IV - 12 bytes random initialization vector + # * authentication tag - 16 bytes authentication tag generated by the GCM mode, covering version and salt + # + # Considerations about V2: + # + # 1) It uses non URL-safe Base64 encoding as it's faster than its + # URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is + # roughly equivalent to + # + # Base64.strict_encode64(data).tr("-_", "+/") + # + # - and cookie values don't need to be URL-safe. + def initialize(delegate, secret, purpose: nil, **options) + super(delegate) + + raise ArgumentError, 'secret must be a String' unless secret.is_a?(String) + + unless secret.bytesize >= 32 + raise ArgumentError, "invalid secret: it's #{secret.bytesize}-byte long, must be >=32" + end + + @purpose = purpose + + @cipher_secret = secret.dup.force_encoding(Encoding::BINARY).slice!(0, 32) + @cipher_secret.freeze + end + + def load(data) + super(decrypt(data)) + end + + def dump(value) + encrypt(super(value)) + end + + private + + def decrypt(base64_data) + data = Base64.strict_decode64(base64_data) + + if data.bytesize <= 61 # version + salt + iv + auth_tag = 61 byte (and we also need some ciphertext :) + raise InvalidMessage, 'invalid message' + end + + version = data[0] + raise InvalidMessage, 'invalid message' unless version == "\2" + + ciphertext = data.slice!(61..-1) + auth_tag = data.slice!(45, 16) + cipher_iv = data.slice!(33, 12) + + cipher = new_cipher + cipher.decrypt + salt = data.slice(1, 32) + set_cipher_key(cipher, message_secret_from_salt(salt)) + cipher.iv = cipher_iv + cipher.auth_tag = auth_tag + cipher.auth_data = @purpose ? data + @purpose : data + + plaintext = cipher.update(ciphertext) << cipher.final + + deserialized_message plaintext + rescue ArgumentError, OpenSSL::Cipher::CipherError + raise InvalidSignature, 'invalid message' + end + + def encrypt(message) + version = "\2" + + serialized_payload = serialize_payload(message) + + cipher = new_cipher + cipher.encrypt + salt, message_secret = new_salt_and_message_secret + set_cipher_key(cipher, message_secret) + cipher.iv_len = 12 + cipher_iv = cipher.random_iv + + data = String.new + data << version + data << salt + + cipher.auth_data = @purpose ? data + @purpose : data + encrypted_data = cipher.update(serialized_payload) << cipher.final + + data << cipher_iv + data << auth_tag_from(cipher) + data << encrypted_data + + Base64.strict_encode64(data) + end + + def new_cipher + OpenSSL::Cipher.new('aes-256-gcm') + end + + def new_salt_and_message_secret + salt = SecureRandom.random_bytes(32) + + [salt, message_secret_from_salt(salt)] + end + + def message_secret_from_salt(salt) + OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @cipher_secret, salt) + end + + def set_cipher_key(cipher, key) + cipher.key = key + end + + if RUBY_ENGINE == 'jruby' + # JRuby's OpenSSL implementation doesn't currently support passing + # an argument to #auth_tag. Here we work around that. + def auth_tag_from(cipher) + tag = cipher.auth_tag + raise Error, 'the auth tag must be 16 bytes long' if tag.bytesize != 16 + + tag + end + else + def auth_tag_from(cipher) + cipher.auth_tag(16) + end + end + end + end + end +end diff --git a/lib/rack/session/payload/padded.rb b/lib/rack/session/payload/padded.rb new file mode 100644 index 0000000..ef6ccf1 --- /dev/null +++ b/lib/rack/session/payload/padded.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "wrapper" + +module Rack + module Session + module Payload + class Padded < Wrapper + MAXIMUM_SIZE = 4096 + + def initialize(delegate, size) + super(delegate) + + if size < 2 or size > MAXIMUM_SIZE + raise ArgumentError, "Size must be between 2 and #{MAXIMUM_SIZE}!" + end + + @size = size + end + + # 2-byte padding size: + FORMAT = "v" + + # @returns [String] The serialized value, with padding. The first 2 bytes of the data indicate the amount of padding. + def dump(...) + data = super(...) + + # 2 bytes for padding size, appended to the start of the data: + padding_size = @size - (data.bytesize + 2) % @size + padding_bytes = SecureRandom.random_bytes(padding_size) + + return [padding_size].pack(FORMAT) + padding_bytes + data + end + + # @returns [Object] The deserialized value, with padding removed. + def load(data) + padding_size, _ = data.unpack(FORMAT) + + data = data.slice(padding_size+2, data.bytesize) + + return super(data) + end + end + end + end +end diff --git a/lib/rack/session/payload/wrapper.rb b/lib/rack/session/payload/wrapper.rb new file mode 100644 index 0000000..3ec0092 --- /dev/null +++ b/lib/rack/session/payload/wrapper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +module Rack + module Session + module Payload + class Wrapper + def initialize(delegate) + @delegate = delegate + end + + # Load the payload from the given value. + # + # @parameter value [String] The value to load the payload from, typically the session cookie. + def load(data) + @delegate.load(data) + end + + # Dump the payload to a string. + # + # @parameter value [Object] The payload to dump. + def dump(value, **options) + @delegate.dump(value, **options) + end + end + end + end +end diff --git a/rack-session.gemspec b/rack-session.gemspec index 16c06de..2d746c4 100644 --- a/rack-session.gemspec +++ b/rack-session.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.files = Dir['{lib}/**/*', '*.md'] - spec.required_ruby_version = ">= 2.5" + spec.required_ruby_version = ">= 3.1" spec.metadata = { "rubygems_mfa_required" => "true" @@ -22,10 +22,4 @@ Gem::Specification.new do |spec| spec.add_dependency "base64", ">= 0.1.0" spec.add_dependency "rack", ">= 3.0.0" - - spec.add_development_dependency "bundler" - spec.add_development_dependency "minitest", "~> 5.0" - spec.add_development_dependency "minitest-global_expectations" - spec.add_development_dependency "minitest-sprint" - spec.add_development_dependency "rake" end diff --git a/sus/rack/session/encryptor.rb b/sus/rack/session/encryptor.rb new file mode 100644 index 0000000..38b10b3 --- /dev/null +++ b/sus/rack/session/encryptor.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require "rack/session/encryptor" + +AValidSessionCookie = Sus::Shared("a valid session cookie") do |value, cookie| + it "can decrypt a valid session cookie" do + expect(encryptor.decrypt(cookie)).to be == value + rescue + pp encryptor.encrypt(value) + end + + it "can encrypt a valid session cookie" do + encrypted = encryptor.encrypt(value) + expect(encryptor.decrypt(encrypted)).to be == value + end +end + +describe Rack::Session::Encryptor do + with "generic encryptor" do + let(:secret) {"." * 64} + let(:encryptor) {Rack::Session::Encryptor.new(secret)} + + # v2.0.0 (salted) + it_behaves_like AValidSessionCookie, {}, "AbGjZyIjUxHlH1vpbU0yDc8N8MB0SV723Fj2uGYWrXxHuh99KpUiavHdw2gG1IW7e2OISpZetJzALqc_LxtVigllj9HZ7mzF1KvHPabLvfJcJ0gB_MqHGTKUMPlsSs9L9frJJuazsd1aW-7C33LB6Eg=" + it_behaves_like AValidSessionCookie, {"a" => 10}, "AbxvYQw-Fz0HW2g6uo3uSVheVv1-QaWGtg2LSVsTYq1Sds8xNLjGDCKvwACHJN1tiBA_jOoiP64kmjZRGnTBl0pv9SqsKwaRBKnY1Q5Rkc621pVlRlr98ehyYhfJT_svoUJXCYfrWgoGO_n8zj4Cgi4=" + it_behaves_like AValidSessionCookie, {"a" => 10, "b" => 20}, "ATfvdnPvcdW4ohIm7MnIdBLKwvqK4j58Rt9hQZhkifZq9IW15sDUViOMM0ClaLONci1fChW0pTuLmFxK3tQ0ch7GxiTVNqfNI1GFlC2epQr-bkuWNF0AbpC4FzBhn94RVQ1MLdd1pPaFQF6E40VvUjw=" + it_behaves_like AValidSessionCookie, {a: 10, b: 20}, "AV6-1fg2bphnDYJvj4B34LiDed0QLzBsxboLVerKCk2Nv9_HBaOeTOCJMrkW946IHS3HxXJ10hFrXTlHTJLn9m96SRlGODJjGTu1ltTcFpiQo-dikKvCDmuIP-4t0cxEVl2pYfgO3wexd9MrJzOKuzQ=" + end +end diff --git a/test/spec_session_encryptor.rb b/test/spec_session_encryptor.rb index eeb95fe..172aa85 100644 --- a/test/spec_session_encryptor.rb +++ b/test/spec_session_encryptor.rb @@ -4,16 +4,17 @@ # Copyright, 2022-2023, by Samuel Williams. require_relative 'helper' + require 'rack/session/encryptor' require 'base64' require 'json' require 'securerandom' -def all_versions_tests(opts = {}) +def all_versions_tests(options = {}) Module.new do - define_method(:default_opts) do - opts + define_method(:default_options) do + options end def self.included(_base) @@ -35,15 +36,15 @@ def self.included(_base) it 'decrypts an encrypted message' do encryptor = new_encryptor(@secret) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) - encryptor.decrypt(message).must_equal({ 'foo' => 'bar' }) + encryptor.load(message).must_equal({ 'foo' => 'bar' }) end it 'decrypt raises InvalidSignature for tampered messages' do encryptor = new_encryptor(@secret) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) decoded_message = Base64.urlsafe_decode64(message) tampered_message = Base64.urlsafe_encode64(decoded_message.tap do |m| @@ -51,23 +52,23 @@ def self.included(_base) end) lambda { - encryptor.decrypt(tampered_message) + encryptor.load(tampered_message) }.must_raise Rack::Session::Encryptor::InvalidSignature end it 'decrypts an encrypted message with purpose' do encryptor = new_encryptor(@secret, purpose: 'testing') - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) - encryptor.decrypt(message).must_equal({ 'foo' => 'bar' }) + encryptor.load(message).must_equal({ 'foo' => 'bar' }) end it 'decrypts an encrypted message with UTF-8 data' do encryptor = new_encryptor(@secret) - encrypted_message = encryptor.encrypt({ 'foo' => 'bar 😀' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ 'foo' => 'bar 😀' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ 'foo' => 'bar 😀' }) end @@ -76,46 +77,46 @@ def self.included(_base) encryptor = new_encryptor(@secret, purpose: 'testing') other_encryptor = new_encryptor(@secret) - message = other_encryptor.encrypt({ 'foo' => 'bar' }) + message = other_encryptor.dump({ 'foo' => 'bar' }) - -> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature + -> { encryptor.load(message) }.must_raise Rack::Session::Encryptor::InvalidSignature end it 'decrypts raises InvalidSignature with different purpose' do encryptor = new_encryptor(@secret, purpose: 'testing') other_encryptor = new_encryptor(@secret, purpose: 'other') - message = other_encryptor.encrypt({ 'foo' => 'bar' }) + message = other_encryptor.dump({ 'foo' => 'bar' }) - -> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature + -> { encryptor.load(message) }.must_raise Rack::Session::Encryptor::InvalidSignature end it 'initialize raises ArgumentError on invalid pad_size' do -> { new_encryptor @secret, pad_size: :bar }.must_raise ArgumentError end - it 'initialize raises ArgumentError on to short pad_size' do + it 'initialize raises ArgumentError on too short pad_size' do -> { new_encryptor @secret, pad_size: 1 }.must_raise ArgumentError end - it 'initialize raises ArgumentError on to long pad_size' do + it 'initialize raises ArgumentError on too long pad_size' do -> { new_encryptor @secret, pad_size: 8023 }.must_raise ArgumentError end it 'decrypts an encrypted message without pad_size' do encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) - encryptor.decrypt(message).must_equal({ 'foo' => 'bar' }) + encryptor.load(message).must_equal({ 'foo' => 'bar' }) end it 'encryptor with pad_size increases message size' do no_pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil) pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 64) - message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt('')) - message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt('')) + message_without = Base64.urlsafe_decode64(no_pad_encryptor.dump('')) + message_with = Base64.urlsafe_decode64(pad_encryptor.dump('')) message_size_diff = message_with.bytesize - message_without.bytesize message_with.bytesize.must_be :>, message_without.bytesize @@ -132,25 +133,27 @@ def setup @secret = SecureRandom.random_bytes(64) end - def new_encryptor(secret, opts = {}) - if respond_to?(:default_opts) - encryptor_class.new(secret, default_opts.merge(opts)) - else - encryptor_class.new(secret, opts) + def new_encryptor(secret, **options) + if respond_to?(:default_options) + options = default_options.merge(options) end + + delegate = options.delete(:delegate) || Marshal + + return encryptor_class.new(delegate, secret, **options) end describe 'V1' do def encryptor_class - Rack::Session::Encryptor::V1 + Rack::Session::Payload::EncryptedV1 end - include all_versions_tests(serialize_json: false) - include all_versions_tests(serialize_json: true) + include all_versions_tests(delegate: Marshal) + include all_versions_tests(delegate: JSON) it 'encryptor with pad_size has message payload size to multiple of pad_size' do encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt({ 'foo' => 'bar' * 4 }) + message = encryptor.dump({ 'foo' => 'bar' * 4 }) decoded_message = Base64.urlsafe_decode64(message) @@ -168,14 +171,14 @@ def encryptor_class cipher = OpenSSL::Cipher.new('aes-256-ctr') encryptor = new_encryptor(@secret) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) raw_message = Base64.urlsafe_decode64(message) _ver = raw_message.slice!(0, 1) key = raw_message.slice!(0, 32) iv = raw_message.slice!(0, 16) - cipher.decrypt + cipher.load cipher.key = key cipher.iv = iv @@ -192,7 +195,7 @@ def encryptor_class it 'it calls set_cipher_key with the correct key' do encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) message_key = Base64.urlsafe_decode64(message).slice(1, 32) @@ -204,15 +207,15 @@ def encryptor_class end encryptor.stub :set_cipher_key, callable do - encryptor.decrypt message + encryptor.load message end end it 'preserves symbols when payloads are not encoded into JSON' do encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false) - encrypted_message = encryptor.encrypt({ foo: 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ foo: 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ foo: 'bar' }) end @@ -220,8 +223,8 @@ def encryptor_class it 'does not preserves symbols when payloads are encoded into JSON' do encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true) - encrypted_message = encryptor.encrypt({ foo: 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ foo: 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ 'foo' => 'bar' }) end @@ -229,15 +232,15 @@ def encryptor_class describe 'V2' do def encryptor_class - Rack::Session::Encryptor::V2 + Rack::Session::Payload::EncryptedV2 end - include all_versions_tests(serialize_json: false) - include all_versions_tests(serialize_json: true) + include all_versions_tests(delegate: Marshal) + include all_versions_tests(delegate: JSON) it 'encryptor with pad_size has message payload size to multiple of pad_size' do encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt({ 'foo' => 'bar' * 4 }) + message = encryptor.dump({ 'foo' => 'bar' * 4 }) decoded_message = Base64.strict_decode64(message) @@ -250,13 +253,13 @@ def encryptor_class it 'raises InvalidMessage on version mismatch' do encryptor = new_encryptor(@secret, purpose: 'testing') - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) decoded_message = Base64.strict_decode64(message) decoded_message[0] = "\1" reencoded_message = Base64.strict_encode64(decoded_message) - -> { encryptor.decrypt(reencoded_message) }.must_raise Rack::Session::Encryptor::InvalidMessage + -> { encryptor.load(reencoded_message) }.must_raise Rack::Session::Encryptor::InvalidMessage end # This test checks the one-time message key IS NOT used as the cipher key. @@ -266,7 +269,7 @@ def encryptor_class cipher = OpenSSL::Cipher.new('aes-256-gcm') encryptor = new_encryptor(@secret) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) raw_message = Base64.strict_decode64(message) version = raw_message.slice!(0, 1) @@ -274,7 +277,7 @@ def encryptor_class iv = raw_message.slice!(0, 12) auth_tag = raw_message.slice!(0, 16) - cipher.decrypt + cipher.load cipher.key = salt cipher.iv = iv cipher.auth_tag = auth_tag @@ -285,7 +288,7 @@ def encryptor_class it 'it calls set_cipher_key with the correct key' do encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24) - message = encryptor.encrypt({ 'foo' => 'bar' }) + message = encryptor.dump({ 'foo' => 'bar' }) message_key = Base64.strict_decode64(message).slice(1, 32) @@ -297,15 +300,15 @@ def encryptor_class end encryptor.stub :set_cipher_key, callable do - encryptor.decrypt message + encryptor.load message end end it 'preserves symbols when payloads are not encoded into JSON' do encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false) - encrypted_message = encryptor.encrypt({ foo: 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ foo: 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ foo: 'bar' }) end @@ -313,8 +316,8 @@ def encryptor_class it 'does not preserves symbols when payloads are encoded into JSON' do encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true) - encrypted_message = encryptor.encrypt({ foo: 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ foo: 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ 'foo' => 'bar' }) end @@ -324,7 +327,7 @@ def encryptor_class it 'encrypts the message with encrytor v1 when initialitialized with mode v1' do encryptor = Rack::Session::Encryptor.new(@secret, { mode: :v1 }) - encrypted_message = encryptor.encrypt({ 'foo' => 'bar' }) + encrypted_message = encryptor.dump({ 'foo' => 'bar' }) version = Base64.urlsafe_decode64(encrypted_message)[0] version.must_equal "\1" @@ -333,7 +336,7 @@ def encryptor_class it 'encrypts the message with encrytor v1 when initialitialized with a mode other than v1' do encryptor = Rack::Session::Encryptor.new(@secret, { mode: :not_v1 }) - encrypted_message = encryptor.encrypt({ 'foo' => 'bar' }) + encrypted_message = encryptor.dump({ 'foo' => 'bar' }) version = Base64.strict_decode64(encrypted_message)[0] version.must_equal "\2" @@ -344,8 +347,8 @@ def encryptor_class it 'decrypts the message with encryptor v1 when initialized with mode v1' do encryptor = Rack::Session::Encryptor.new(@secret, { mode: :v1 }) - encrypted_message = encryptor.encrypt({ 'foo' => 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ 'foo' => 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ 'foo' => 'bar' }) end @@ -353,8 +356,8 @@ def encryptor_class it 'decrypts the message with encryptor v2 when initialized with mode v2' do encryptor = Rack::Session::Encryptor.new(@secret, { mode: :v2 }) - encrypted_message = encryptor.encrypt({ 'foo' => 'bar' }) - decrypted_message = encryptor.decrypt(encrypted_message) + encrypted_message = encryptor.dump({ 'foo' => 'bar' }) + decrypted_message = encryptor.load(encrypted_message) decrypted_message.must_equal({ 'foo' => 'bar' }) end @@ -364,11 +367,11 @@ def encryptor_class encryptor_mode_v1 = Rack::Session::Encryptor.new(@secret, { mode: :v1 }) encryptor_mode_v2 = Rack::Session::Encryptor.new(@secret, { mode: :v2 }) - encrypted_message_v1 = encryptor_mode_v1.encrypt({ 'foo' => 'bar' }) - encrypted_message_v2 = encryptor_mode_v2.encrypt({ 'foo' => 'bar' }) + encrypted_message_v1 = encryptor_mode_v1.dump({ 'foo' => 'bar' }) + encrypted_message_v2 = encryptor_mode_v2.dump({ 'foo' => 'bar' }) - decrypted_message_v1 = encryptor_without_mode.decrypt(encrypted_message_v1) - decrypted_message_v2 = encryptor_without_mode.decrypt(encrypted_message_v2) + decrypted_message_v1 = encryptor_without_mode.load(encrypted_message_v1) + decrypted_message_v2 = encryptor_without_mode.load(encrypted_message_v2) decrypted_message_v1.must_equal({ 'foo' => 'bar' }) decrypted_message_v2.must_equal({ 'foo' => 'bar' })