lib/roda/plugins/sessions.rb in roda-3.18.0 vs lib/roda/plugins/sessions.rb in roda-3.19.0

- old
+ new

@@ -18,12 +18,13 @@ class Roda module RodaPlugins # The sessions plugin adds support for sessions using cookies. It is the recommended # way to support sessions in Roda applications. # - # The session cookies are encrypted with AES-256-CTR and then signed with HMAC-SHA-256. - # By default, session data is padded to reduce information leaked based on the session size. + # The session cookies are encrypted with AES-256-CTR using a separate encryption key per cookie, + # and then signed with HMAC-SHA-256. By default, session data is padded to reduce information + # leaked based on the session size. # # Sessions are serialized via JSON, so session information should only store data that # allows roundtrips via JSON (String, Integer, Float, Array, Hash, true, false, and nil). # In particular, note that Symbol does not round trip via JSON, so symbols should not be # used in sessions when this plugin is used. This plugin sets the @@ -75,10 +76,16 @@ # checks. # :old_secret :: The previous secret to use, allowing for secret rotation. Must be a string of at least 64 # bytes if given. # :pad_size :: Pad session data (after possible compression, before encryption), to a multiple of this # many bytes (default: 32). This can be between 2-4096 bytes, or +nil+ to disable padding. + # :per_cookie_cipher_secret :: Uses a separate cipher key for every cookie, with the key used generated using + # HMAC-SHA-256 of 32 bytes of random data with the default cipher secret. This + # offers additional protection in case the random initialization vector used when + # encrypting the session data has been reused. Odds of that are 1 in 2**64 if + # initialization vector is truly random, but weaknesses in the random number + # generator could make the odds much higher. Default is +true+. # :parser :: The parser for the serialized session data (default: <tt>JSON.method(:parse)</tt>). # :serializer :: The serializer for the session data (default +:to_json.to_proc+). # :skip_within :: If the last update time for the session cookie is less than this number of seconds from the # current time, and the session has not been modified, do not set a new session cookie # (default: 3600). @@ -103,17 +110,22 @@ # <tt>require 'roda/session_middleware'</tt> and <tt>use RodaSessionMiddleware</tt>. # <tt>RodaSessionMiddleware</tt> passes the options given to this plugin. # # = Session Cookie Cryptography/Format # - # Session cookies created by this plugin use the following format: + # Session cookies created by this plugin by default use the following format: # - # urlsafe_base64(version + IV + auth tag + encrypted session data + HMAC) + # urlsafe_base64("\1" + random_data + IV + encrypted session data + HMAC) # + # If +:per_cookie_cipher_secret+ option is set to +false+, an older format is used: + # + # urlsafe_base64("\0" + IV + encrypted session data + HMAC) + # # where: # - # version :: 1 byte, currently must be 0, other values reserved for future expansion. + # version :: 1 byte, currently must be 1 or 0, other values reserved for future expansion. + # random_data :: 32 bytes, used for generating the per-cookie secret # IV :: 16 bytes, initialization vector for AES-256-CTR cipher. # encrypted session data :: >=12 bytes of data encrypted with AES-256-CTR cipher, see below. # HMAC :: 32 bytes, HMAC-SHA-256 of all preceding data plus cookie key (so that a cookie value # for a different key cannot be used even if the secret is the same). # @@ -139,10 +151,11 @@ DEFLATE_BIT = 0x1000 PADDING_MASK = 0x0fff SESSION_CREATED_AT = 'roda.session.created_at'.freeze SESSION_UPDATED_AT = 'roda.session.updated_at'.freeze SESSION_SERIALIZED = 'roda.session.serialized'.freeze + SESSION_VERSION_NUM = 'roda.session.version'.freeze SESSION_DELETE_RACK_COOKIE = 'roda.session.delete_rack_session_cookie'.freeze # Exception class used when creating a session cookie that would exceed the # allowable cookie size limit. class CookieTooLarge < RodaError @@ -164,10 +177,13 @@ co = opts[:cookie_options] = DEFAULT_COOKIE_OPTIONS.merge(opts[:cookie_options] || OPTS).freeze opts[:remove_cookie_options] = co.merge(:max_age=>'0', :expires=>Time.at(0)) opts[:parser] ||= app.opts[:json_parser] || JSON.method(:parse) opts[:serializer] ||= app.opts[:json_serializer] || :to_json.to_proc + opts[:per_cookie_cipher_secret] = true unless opts.has_key?(:per_cookie_cipher_secret) + opts[:session_version_num] = opts[:per_cookie_cipher_secret] ? 1 : 0 + if opts[:upgrade_from_rack_session_cookie_secret] opts[:upgrade_from_rack_session_cookie_key] ||= 'rack.session' rsco = opts[:upgrade_from_rack_session_cookie_options] = Hash[opts[:upgrade_from_rack_session_cookie_options] || OPTS] rsco[:path] ||= co[:path] rsco[:domain] ||= co[:domain] @@ -315,27 +331,38 @@ begin data = Base64.urlsafe_decode64(data) rescue ArgumentError return _session_serialization_error("Unable to decode session: invalid base64") end - length = data.bytesize - if data.length < 61 - # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac) + + case version = data.getbyte(0) + when 1 + per_cookie_secret = true + # minimum length (1+32+16+12+32) (version+random_data+cipher_iv+minimum session+hmac) # 1 : version + # 32 : random_data (if per_cookie_cipher_secret) # 16 : cipher_iv # 12 : minimum_session # 2 : bitmap for gzip + padding info # 4 : creation time # 4 : update time # 2 : data # 32 : HMAC-SHA-256 - return _session_serialization_error("Unable to decode session: data too short") + min_data_length = 93 + when 0 + per_cookie_secret = false + # minimum length (1+16+12+32) (version+cipher_iv+minimum session+hmac) + min_data_length = 61 + when nil + return _session_serialization_error("Unable to decode session: no data") + else + return _session_serialization_error("Unable to decode session: version marker unsupported") end - unless data.getbyte(0) == 0 - # version marker - return _session_serialization_error("Unable to decode session: version marker unsupported") + length = data.bytesize + if data.length < min_data_length + return _session_serialization_error("Unable to decode session: data too short") end encrypted_data = data.slice!(0, length-32) unless Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], encrypted_data+opts[:key])) if opts[:old_hmac_secret] && Rack::Utils.secure_compare(data, OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:old_hmac_secret], encrypted_data+opts[:key])) @@ -343,20 +370,28 @@ else return _session_serialization_error("Not decoding session: HMAC invalid") end end + # Remove version encrypted_data.slice!(0) + + cipher_secret = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret] + if per_cookie_secret + cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, encrypted_data.slice!(0, 32)) + end + cipher_iv = encrypted_data.slice!(0, 16) + cipher = OpenSSL::Cipher.new("aes-256-ctr") # Not rescuing cipher errors. If there is an error in the decryption, that's # either a bug in the plugin that needs to be fixed, or an attacker is already # able to forge a valid HMAC, in which case the error should be raised to # alert the application owner about the problem. cipher.decrypt - cipher.key = opts[use_old_cipher_secret ? :old_cipher_secret : :cipher_secret] - cipher_iv = cipher.iv = encrypted_data.slice!(0, 16) + cipher.key = cipher_secret + cipher.iv = cipher_iv data = cipher.update(encrypted_data) << cipher.final bitmap, created_at, updated_at = data.unpack('vVV') padding_bytes = bitmap & PADDING_MASK if (max = opts[:max_seconds]) && Time.now.to_i > created_at + max @@ -374,10 +409,11 @@ env = @env env[SESSION_CREATED_AT] = created_at env[SESSION_UPDATED_AT] = updated_at env[SESSION_SERIALIZED] = data + env[SESSION_VERSION_NUM] = version opts[:parser].call(data) end def _serialize_session(session) @@ -385,10 +421,11 @@ env = @env now = Time.now.to_i json_data = opts[:serializer].call(session).force_encoding('BINARY') if (serialized_session = env[SESSION_SERIALIZED]) && + (opts[:session_version_num] == env[SESSION_VERSION_NUM]) && (updated_at = env[SESSION_UPDATED_AT]) && (now - updated_at < opts[:skip_within]) && (serialized_session == json_data) return end @@ -416,17 +453,27 @@ serialized_data = [bitmap, session_create_time||now, now].pack('vVV') serialized_data << padding_data if padding_data serialized_data << json_data + cipher_secret = opts[:cipher_secret] + if per_cookie_secret = opts[:per_cookie_cipher_secret] + version = "\1" + per_cookie_secret_base = SecureRandom.random_bytes(32) + cipher_secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, cipher_secret, per_cookie_secret_base) + else + version = "\0" + end + cipher = OpenSSL::Cipher.new("aes-256-ctr") cipher.encrypt - cipher.key = opts[:cipher_secret] + cipher.key = cipher_secret cipher_iv = cipher.random_iv encrypted_data = cipher.update(serialized_data) << cipher.final data = String.new - data << "\0" # version marker + data << version + data << per_cookie_secret_base if per_cookie_secret_base data << cipher_iv data << encrypted_data data << OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, opts[:hmac_secret], data+opts[:key]) data = Base64.urlsafe_encode64(data)