require "openid/dh" require "openid/util" require "openid/kvpost" require "openid/cryptutil" require "openid/protocolerror" require "openid/association" module OpenID class Consumer # A superclass for implementing Diffie-Hellman association sessions. class DiffieHellmanSession class << self attr_reader :session_type, :secret_size, :allowed_assoc_types, :hashfunc end def initialize(dh=nil) if dh.nil? dh = DiffieHellman.from_defaults end @dh = dh end # Return the query parameters for requesting an association # using this Diffie-Hellman association session def get_request args = {'dh_consumer_public' => CryptUtil.num_to_base64(@dh.public)} if (!@dh.using_default_values?) args['dh_modulus'] = CryptUtil.num_to_base64(@dh.modulus) args['dh_gen'] = CryptUtil.num_to_base64(@dh.generator) end return args end # Process the response from a successful association request and # return the shared secret for this association def extract_secret(response) dh_server_public64 = response.get_arg(OPENID_NS, 'dh_server_public', NO_DEFAULT) enc_mac_key64 = response.get_arg(OPENID_NS, 'enc_mac_key', NO_DEFAULT) dh_server_public = CryptUtil.base64_to_num(dh_server_public64) enc_mac_key = Util.from_base64(enc_mac_key64) return @dh.xor_secret(self.class.hashfunc, dh_server_public, enc_mac_key) end end # A Diffie-Hellman association session that uses SHA1 as its hash # function class DiffieHellmanSHA1Session < DiffieHellmanSession @session_type = 'DH-SHA1' @secret_size = 20 @allowed_assoc_types = ['HMAC-SHA1'] @hashfunc = CryptUtil.method(:sha1) end # A Diffie-Hellman association session that uses SHA256 as its hash # function class DiffieHellmanSHA256Session < DiffieHellmanSession @session_type = 'DH-SHA256' @secret_size = 32 @allowed_assoc_types = ['HMAC-SHA256'] @hashfunc = CryptUtil.method(:sha256) end # An association session that does not use encryption class NoEncryptionSession class << self attr_reader :session_type, :allowed_assoc_types end @session_type = 'no-encryption' @allowed_assoc_types = ['HMAC-SHA1', 'HMAC-SHA256'] def get_request return {} end def extract_secret(response) mac_key64 = response.get_arg(OPENID_NS, 'mac_key', NO_DEFAULT) return Util.from_base64(mac_key64) end end # An object that manages creating and storing associations for an # OpenID provider endpoint class AssociationManager def self.create_session(session_type) case session_type when 'no-encryption' NoEncryptionSession.new when 'DH-SHA1' DiffieHellmanSHA1Session.new when 'DH-SHA256' DiffieHellmanSHA256Session.new else raise ArgumentError, "Unknown association session type: "\ "#{session_type.inspect}" end end def initialize(store, server_url, compatibility_mode=false, negotiator=nil) @store = store @server_url = server_url @compatibility_mode = compatibility_mode @negotiator = negotiator || DefaultNegotiator end def get_association if @store.nil? return nil end assoc = @store.get_association(@server_url) if assoc.nil? || assoc.expires_in <= 0 assoc = negotiate_association if !assoc.nil? @store.store_association(@server_url, assoc) end end return assoc end def negotiate_association assoc_type, session_type = @negotiator.get_allowed_type begin return request_association(assoc_type, session_type) rescue ServerError => why supported_types = extract_supported_association_type(why, assoc_type) if !supported_types.nil? # Attempt to create an association from the assoc_type and # session_type that the server told us it supported. assoc_type, session_type = supported_types begin return request_association(assoc_type, session_type) rescue ServerError => why Util.log("Server #{@server_url} refused its suggested " \ "association type: session_type=#{session_type}, " \ "assoc_type=#{assoc_type}") return nil end end end end protected def extract_supported_association_type(server_error, assoc_type) # Any error message whose code is not 'unsupported-type' should # be considered a total failure. if (server_error.error_code != 'unsupported-type' or server_error.message.is_openid1) Util.log("Server error when requesting an association from "\ "#{@server_url}: #{server_error.error_text}") return nil end # The server didn't like the association/session type that we # sent, and it sent us back a message that might tell us how to # handle it. Util.log("Unsupported association type #{assoc_type}: "\ "#{server_error.error_text}") # Extract the session_type and assoc_type from the error message assoc_type = server_error.message.get_arg(OPENID_NS, 'assoc_type') session_type = server_error.message.get_arg(OPENID_NS, 'session_type') if assoc_type.nil? or session_type.nil? Util.log("Server #{@server_url} responded with unsupported "\ "association session but did not supply a fallback.") return nil elsif !@negotiator.allowed?(assoc_type, session_type) Util.log("Server sent unsupported session/association type: "\ "session_type=#{session_type}, assoc_type=#{assoc_type}") return nil else return [assoc_type, session_type] end end # Make and process one association request to this endpoint's OP # endpoint URL. Returns an association object or nil if the # association processing failed. Raises ServerError when the # remote OpenID server returns an error. def request_association(assoc_type, session_type) assoc_session, args = create_associate_request(assoc_type, session_type) begin response = OpenID.make_kv_post(args, @server_url) return extract_association(response, assoc_session) rescue HTTPStatusError => why Util.log("Got HTTP status error when requesting association: #{why}") return nil rescue Message::KeyNotFound => why Util.log("Missing required parameter in response from "\ "#{@server_url}: #{why}") return nil rescue ProtocolError => why Util.log("Protocol error processing response from #{@server_url}: "\ "#{why}") return nil end end # Create an association request for the given assoc_type and # session_type. Returns a pair of the association session object # and the request message that will be sent to the server. def create_associate_request(assoc_type, session_type) assoc_session = self.class.create_session(session_type) args = { 'mode' => 'associate', 'assoc_type' => assoc_type, } if !@compatibility_mode args['ns'] = OPENID2_NS end # Leave out the session type if we're in compatibility mode # *and* it's no-encryption. if !@compatibility_mode || assoc_session.class.session_type != 'no-encryption' args['session_type'] = assoc_session.class.session_type end args.merge!(assoc_session.get_request) message = Message.from_openid_args(args) return assoc_session, message end # Given an association response message, extract the OpenID 1.X # session type. Returns the association type for this message # # This function mostly takes care of the 'no-encryption' default # behavior in OpenID 1. # # If the association type is plain-text, this function will # return 'no-encryption' def get_openid1_session_type(assoc_response) # If it's an OpenID 1 message, allow session_type to default # to nil (which signifies "no-encryption") session_type = assoc_response.get_arg(OPENID1_NS, 'session_type') # Handle the differences between no-encryption association # respones in OpenID 1 and 2: # no-encryption is not really a valid session type for # OpenID 1, but we'll accept it anyway, while issuing a # warning. if session_type == 'no-encryption' Util.log("WARNING: #{@server_url} sent 'no-encryption'"\ "for OpenID 1.X") # Missing or empty session type is the way to flag a # 'no-encryption' response. Change the session type to # 'no-encryption' so that it can be handled in the same # way as OpenID 2 'no-encryption' respones. elsif session_type == '' || session_type.nil? session_type = 'no-encryption' end return session_type end def self.extract_expires_in(message) # expires_in should be a base-10 string. expires_in_str = message.get_arg(OPENID_NS, 'expires_in', NO_DEFAULT) if !(/\A\d+\Z/ =~ expires_in_str) raise ProtocolError, "Invalid expires_in field: #{expires_in_str}" end expires_in_str.to_i end # Attempt to extract an association from the response, given the # association response message and the established association # session. def extract_association(assoc_response, assoc_session) # Extract the common fields from the response, raising an # exception if they are not found assoc_type = assoc_response.get_arg(OPENID_NS, 'assoc_type', NO_DEFAULT) assoc_handle = assoc_response.get_arg(OPENID_NS, 'assoc_handle', NO_DEFAULT) expires_in = self.class.extract_expires_in(assoc_response) # OpenID 1 has funny association session behaviour. if assoc_response.is_openid1 session_type = get_openid1_session_type(assoc_response) else session_type = assoc_response.get_arg(OPENID2_NS, 'session_type', NO_DEFAULT) end # Session type mismatch if assoc_session.class.session_type != session_type if (assoc_response.is_openid1 and session_type == 'no-encryption') # In OpenID 1, any association request can result in a # 'no-encryption' association response. Setting # assoc_session to a new no-encryption session should # make the rest of this function work properly for # that case. assoc_session = NoEncryptionSession.new else # Any other mismatch, regardless of protocol version # results in the failure of the association session # altogether. raise ProtocolError, "Session type mismatch. Expected "\ "#{assoc_session.class.session_type}, got "\ "#{session_type}" end end # Make sure assoc_type is valid for session_type if !assoc_session.class.allowed_assoc_types.member?(assoc_type) raise ProtocolError, "Unsupported assoc_type for session "\ "#{assoc_session.class.session_type} "\ "returned: #{assoc_type}" end # Delegate to the association session to extract the secret # from the response, however is appropriate for that session # type. begin secret = assoc_session.extract_secret(assoc_response) rescue Message::KeyNotFound, ArgumentError => why raise ProtocolError, "Malformed response for "\ "#{assoc_session.class.session_type} "\ "session: #{why.message}" end return Association.from_expires_in(expires_in, assoc_handle, secret, assoc_type) end end end end