ruby-on-railsopensslwebhookshmacsha256http-signature

HTTP signature - HMAC-SHA256


I receive an incoming transfer event webhook from the Manager One API. For signature verification, they employ an HMAC-SHA256 algorithm. They provided me with a shared secret key, which is a combination of numbers and letters that I have placed in my ENV["MANAGER_ONE_SECRET_SIGNATURE"]. For this example, let's assume the value is "shared_secret_key." I have been working on matching the key obtained from the request header information with this shared secret key to successfully verify the webhook.

Here's what I have accomplished thus far:

class Api::ManagerOneController < Api::ApplicationController
  def create
    if expected_signature == provided_signature_base64
      request_body = JSON.parse(request.raw_post)["incoming_bank_operation"]
      ManagerOne::Webhooks::IncomingTransferJob.perform_later(request_body)
      render status: :ok, json: { message: "Signature verified successfully" }
    else
      render status: :unauthorized, json: { error: "Unauthorized" }
    end
  end

  private

  def expected_signature
    Base64.strict_encode64(
      OpenSSL::HMAC.digest(
        OpenSSL::Digest.new("sha256"),
        ENV["MANAGER_ONE_SECRET_SIGNATURE"],
        signature_input
      )
    )
  end

  def signature_input
    content_length = "\"content-length\": #{request.headers['Content-Length']}\n"
    user_agent = "\"user-agent\": #{request.headers['User-agent']}\n"
    content_type= "\"content-type\": #{request.headers['Content-Type']}\n"
    host = "\"host\": #{request.headers['Host']}\n"
    request_target = "\"(request-target)\": #{"post #{request.path}"}\n"
    date = "\"date\": #{request.headers['Date']}\n"
    digest = "\"digest\": #{request.headers['Digest']}\n"
    signature_params = "\"@signature-params\": (\"content-length\" \"user-agent\" \"content-type\" \"host\" \"(request-target)\" \"date\" \"digest\"); keyid=\"#{ENV["MANAGER_ONE_SECRET_SIGNATURE"]}\"; algorithm=\"hmac-sha256\";"

    "#{content_length}#{user_agent}#{content_type}#{host}#{request_target}#{date}#{digest}#{signature_params}"
  end

  def provided_signature_base64
    request.headers['Signature'].match(/signature="([^"]+)"/)[1]
  end
end

The headers change with each request; the data I provided is just an example:

expected_signature = "1zmbBUVFEsqlj8G0h8SPdikbu7et4boR/r/HHoLkr2o="

I generated the signature_input by following the instructions provided in this documentation:

signature_input = "\"content-length\": 1107\n\"user-agent\": manager.one\n\"content-type\": application/json\n\"host\": localhost:3000\n\"(request-target)\": post /api/manager_one/webhooks\n\"date\": Mon, 30 Oct 2023 13:02:55 GMT\n\"digest\": SHA-256=sTz9SzsDH3WGcbYSZb+G8Xk1javIUxE0egQaKD1Wyws=\n\"@signature-params\": (\"content-length\" \"user-agent\" \"content-type\" \"host\" \"(request-target)\" \"date\" \"digest\"); keyid=\"shared_secret_key\"; algorithm=\"hmac-sha256\";"

The key I'm supposed to match with the expected signature is the one I obtained from the Manager One API documentation example, although it changes with each request:

provided_signature_base64 = "pkLgUJmM5FixErazNavlwWizmDZdTDBcMZb9xY3ZUv8="

Am I missing something? I can't figure out what I'm doing wrong.


Solution

  • I've figured it out; my signature input had several issues:

    In the end, I resolved these issues and arrived at the correct solution:

    class Api::ManagerOneController < Api::ApplicationController
      def create
        success = expected_signature == provided_signature_base64
        request_body = JSON.parse(request.raw_post)["incoming_bank_operation"]
        ManagerOne::Webhooks::IncomingTransferJob.perform_later(request_body, success)
        render status: :ok, json: { success: true, message: success ? "Authorized" : "Unauthorized" }
      end
    
      private
    
      def expected_signature
        hmac = OpenSSL::HMAC.digest("SHA-256", ENV["MANAGER_ONE_SECRET_SIGNATURE"], signature_input)
        Base64.encode64(hmac).chomp
      end
    
      def signature_input
        content_length = "content-length: #{request.headers['Content-Length']}"
        user_agent = "user-agent: #{request.headers['User-agent']}"
        content_type= "content-type: #{request.headers['Content-Type']}"
        # When using Ngrok in development environment, host must be ngrok url & not localhost:3000
        host = "host: #{request.headers['Host']}"
        request_target = "(request-target): #{"post #{request.path}"}"
        date = "date: #{request.headers['Date']}"
        digest = "digest: #{request.headers['Digest']}"
    
        "#{content_length}\n#{user_agent}\n#{content_type}\n#{host}\n#{request_target}\n#{date}\n#{digest}"
      end
    
      def provided_signature_base64
        request.headers['Signature'].match(/signature="([^"]+)"/)[1]
      end
    end