ruby-samlで「Invalid Signature on SAML Response」が返ってきた場合の調査方法

こんにちは!kossyです!




今回はSSOを実現するGem「ruby-saml」で「Invalid Signature on SAML Response」が返ってきた場合の調査方法について、

備忘録としてブログに残してみたいと思います。





環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
ruby-saml 1.13.0




なお、サンプルのアプリケーションはoneloginが提供している以下のアプリを使っているものとします。

github.com

コードリーディング

fail.html.erbをレンダリングしている処理はこちらでした。

  def acs
    settings = Account.get_saml_settings(get_url_base)
    response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings)

    if response.is_valid?
      session[:nameid] = response.nameid
      session[:attributes] = response.attributes
      @attrs = session[:attributes]
      logger.info "Sucessfully logged"
      logger.info "NAMEID: #{response.nameid}"
      render :action => :index
    else
      logger.info "Response Invalid. Errors: #{response.errors}"
      @errors = response.errors
      render :action => :fail
    end
  end

なので、acsアクションにbinding.pryを定義し、pry-byebug Gemを入れることで使えるようになるstepメソッドを使って、

原因を探りたいと思います。

    20: def acs
    21:   settings = Account.get_saml_settings(get_url_base)
    22:   response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings)
    23:
    24:   binding.pry
    25:
 => 26:   if response.is_valid?
    27:     session[:nameid] = response.nameid
    28:     session[:attributes] = response.attributes
    29:     @attrs = session[:attributes]
    30:     logger.info "Sucessfully logged"
    31:     logger.info "NAMEID: #{response.nameid}"
    32:     render :action => :index
    33:   else
    34:     logger.info "Response Invalid. Errors: #{response.errors}"
    35:     @errors = response.errors
    36:     render :action => :fail
    37:   end
    38: end


$ response.is_valid?
=> false

$ step

From: /usr/local/bundle/gems/ruby-saml-1.13.0/lib/onelogin/ruby-saml/response.rb:79 OneLogin::RubySaml::Response#is_valid?:

    78: def is_valid?(collect_errors = false)
 => 79:   validate(collect_errors)
    80: end

$ step

      # Validates the SAML Response (calls several validation methods)
      # @param collect_errors [Boolean] Stop validation when first error appears or keep validating. (if soft=true)
      # @return [Boolean] True if the SAML Response is valid, otherwise False if soft=True
      # @raise [ValidationError] if soft == false and validation fails
      #
      def validate(collect_errors = false)
        reset_errors!
        return false unless validate_response_state

        validations = [
          :validate_version,
          :validate_id,
          :validate_success_status,
          :validate_num_assertion,
          :validate_no_duplicated_attributes,
          :validate_signed_elements,
          :validate_structure,
          :validate_in_response_to,
          :validate_one_conditions,
          :validate_conditions,
          :validate_one_authnstatement,
          :validate_audience,
          :validate_destination,
          :validate_issuer,
          :validate_session_expiration,
          :validate_subject_confirmation,
          :validate_name_id,
          :validate_signature
        ]

        if collect_errors
          validations.each { |validation| send(validation) }
          @errors.empty?
        else
          validations.all? { |validation| send(validation) }
        end
      end

# ですよね()という結果
$  validations.all? { |validation| send(validation) }
=> false

実際にvalidationを実行しているのは、send(validation)の部分ですね。

ここは全て読むのは辛いので、「Invalid Signature」でruby-samlのコードを検索してみます。

      # Validates the Signature
      # @return [Boolean] True if not contains a Signature or if the Signature is valid, otherwise False if soft=True
      # @raise [ValidationError] if soft == false and validation fails
      #
      def validate_signature
        error_msg = "Invalid Signature on SAML Response"

        # If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
        # otherwise, review if the decrypted assertion contains a signature
        sig_elements = REXML::XPath.match(
          document,
          "/p:Response[@ID=$id]/ds:Signature",
          { "p" => PROTOCOL, "ds" => DSIG },
          { 'id' => document.signed_element_id }
        )

        use_original = sig_elements.size == 1 || decrypted_document.nil?
        doc = use_original ? document : decrypted_document

        # Check signature nodes
        if sig_elements.nil? || sig_elements.size == 0
          sig_elements = REXML::XPath.match(
            doc,
            "/p:Response/a:Assertion[@ID=$id]/ds:Signature",
            {"p" => PROTOCOL, "a" => ASSERTION, "ds"=>DSIG},
            { 'id' => doc.signed_element_id }
          )
        end

        if sig_elements.size != 1
          if sig_elements.size == 0
             append_error("Signed element id ##{doc.signed_element_id} is not found")
          else
             append_error("Signed element id ##{doc.signed_element_id} is found more than once")
          end
          return append_error(error_msg)
        end

        old_errors = @errors.clone

        idp_certs = settings.get_idp_cert_multi
        if idp_certs.nil? || idp_certs[:signing].empty?
          opts = {}
          opts[:fingerprint_alg] = settings.idp_cert_fingerprint_algorithm
          idp_cert = settings.get_idp_cert
          fingerprint = settings.get_fingerprint
          opts[:cert] = idp_cert

          if fingerprint && doc.validate_document(fingerprint, @soft, opts)
            if settings.security[:check_idp_cert_expiration]
              if OneLogin::RubySaml::Utils.is_cert_expired(idp_cert)
                error_msg = "IdP x509 certificate expired"
                return append_error(error_msg)
              end
            end
          else
            return append_error(error_msg)
          end
        else
          valid = false
          expired = false
          idp_certs[:signing].each do |idp_cert|
            valid = doc.validate_document_with_cert(idp_cert, true)
            if valid
              if settings.security[:check_idp_cert_expiration]
                if OneLogin::RubySaml::Utils.is_cert_expired(idp_cert)
                  expired = true
                end
              end

              # At least one certificate is valid, restore the old accumulated errors
              @errors = old_errors
              break
            end

          end
          if expired
            error_msg = "IdP x509 certificate expired"
            return append_error(error_msg)
          end
          unless valid
            # Remove duplicated errors
            @errors = @errors.uniq
            return append_error(error_msg)
          end
        end

        true
      end


validate_signatureメソッドがエラーメッセージを返していそうなので、処理を追ってみます。

$ send(:validate_signature)
=> false

# stepでvalidate_signatureメソッドの内部に移動

# どうやらここがfalseなのが原因
$ if fingerprint && doc.validate_document(fingerprint, @soft, opts)
=> false

$ fingerprint
=> "AB:CD:EF:AB:CD:EF:A1:B2:C3:D4:E5:F6:94:C1:B5:8B:00:23:0A:D8:3D:1B:55:DF:41:EA:42:5F:9E:62:07:85"

$ doc.validate_document(fingerprint, @soft, opts)
=> false

どうやらfingerprintが間違っているのが原因のようです。

    # IdP section
    settings.idp_entity_id                  = "#{idp_base_url}/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_sso_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_slo_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}/logout"
    settings.idp_cert_fingerprint           = "#{ENV['IDP_FINGERPRINT']}"
    settings.idp_cert_fingerprint_algorithm = XMLSecurity::Document::SHA256

settings.idp_cert_fingerprint属性の値に正しいfingerprintを渡してやればOKでした。


まとめ

もし本番運用で「Invalid Signature on SAML Response」に遭遇して、原因が「fingerprintの値が間違っている」だったとして、

原因を特定するまでにかなりの労力を使うような気がしています、、、(エラーメッセージが不親切)

XmlSecurityモジュールのvalidate_documentメソッドも読んでみようと思ったんですが、何をしているのかさっぱりわからなかったので、

Pull Requestのコメントやcommitメッセージを読みつつコードリーディングを進めるのと、

そもそもなぜSAMLによるSSO認証にfingerprintが使われているか、等を調べて知見を深めようと思います、、、