eBay / digital-signature-verification-ebay-api

Verification of digital signatures for use by developers sending HTTP requests to eBay's APIs
Apache License 2.0
8 stars 7 forks source link

Modified code with (same?) functionality does not work #19

Closed BlackEagleXV closed 1 year ago

BlackEagleXV commented 1 year ago

Hi. I have modified the code a bit, because I didn't like the autoloader/json stuff. Unfortunately, it doesn't work. I will add the code more or less, the missing parts can be hardcoded for testing. The most different part is that the headers are processed in raw format and not in array format.

Also it is working with the old version of phpseclib which is working without autoloader as well, so I'm using RSA which should be supported.

Caller:

require_once(SERVER_PATH_INTERNAL . LIB_PATH . "ebay/signature.php");

// how to get keys
function EBYGetKeyPair()
{
    $token = _EBYGetAccessToken(); // gets normal bearer access token

    $response = _EBYSendApiRequest(
        "https://apiz.ebay.com/developer/key_management/v1/signing_key",
        array("Accept: application/json", "Content-Type: application/json", "Authorization: Bearer $token"),
        array("signingKeyCipher" => "RSA"),
        false);

    $json = json_decode($response, true);
    if ($json != null) {
        $keydata = $json; // this data is stored into constants EBAY_FINANCES_PRIVATE_KEY and EBAY_FINANCES_JWE
    }

    return $keydata;
}

// example call
function EBYGetTransactions()
{
    $transactiondata = array();

    $token = _EBYGetAccessToken(); // gets normal bearer access token

    $response = _EBYSendApiRequest(
        "https://apiz.ebay.com/sell/finances/v1/transaction?limit=30&filter=transactionType:{SALE}",
        array("Accept: application/json", "Content-Type: application/json", "Authorization: Bearer $token"),
        null,
        false,
        true);

    $json = json_decode($response, true);
    if ($json != null) {
        $transactiondata = $json;
    }

    return $transactiondata;
}

// method to wrap it up
function _EBYSendApiRequest($url, $headers, $postfields = null, $returnheader = 1, $addsignature = false)
{
    $body = is_array($postfields) === true ? json_encode($postfields) : $postfields;

    if ($addsignature === true) {
        $signature = new Signature(EBAY_FINANCES_PRIVATE_KEY, EBAY_FINANCES_JWE);

        $headers = $signature->generateSignatureHeaders($headers, $url, $body !== null ? "POST" : "GET", $body);
    }

    $handle = curl_init($url);
    curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($handle, CURLHEADER_SEPARATE, true);
    curl_setopt($handle, CURLOPT_FOLLOWLOCATION, false);
    curl_setopt($handle, CURLOPT_HEADER, $returnheader);
    curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false);

    if ($body !== null) {
        curl_setopt($handle, CURLOPT_POST, 1);
        curl_setopt($handle, CURLOPT_POSTFIELDS, $body);
    }

    $response = curl_exec($handle);
    curl_close($handle);

    return $response;
}

Signature.php

<?php
require_once(SERVER_PATH_INTERNAL . LIB_PATH . "ebay/SignatureConfig.php");
require_once(SERVER_PATH_INTERNAL . LIB_PATH . "ebay/SignatureService.php");
require_once(SERVER_PATH_INTERNAL . LIB_PATH . "phpseclib/Math/BigInteger.php");
require_once(SERVER_PATH_INTERNAL . LIB_PATH . "phpseclib/Crypt/RSA.php");

class Signature {

    private SignatureConfig $signatureConfig;
    private SignatureService $signatureService;

    public function __construct(string $privatekeystr, string $jwe) {
        $this->loadSignatureConfig($privatekeystr, $jwe);
        $this->signatureService = new SignatureService();
    }

    /**
     * Returns an array with all required headers. Add this to your HTTP request
     *
     * @param array $headers request headers
     * @param string $endpoint URI of the request
     * @param string $method POST, GET, PUT, etc.
     * @param string|null $body body
     * @return array All headers including the initially transmitted
     */
    public function generateSignatureHeaders(array $headers, string $endpoint, string $method, string $body = null): array {
        $contains_body = !is_null($body);
        if ($contains_body === true) {
            $headers[] = "Content-Digest: " . $this->signatureService->generateContentDigest($body, $this->signatureConfig);
        }
        $timestamp = time();
        $headers[] = "x-ebay-signature-key: " . $this->signatureService->generateSignatureKey($this->signatureConfig);
        $headers[] = "Signature-Input: " . $this->signatureService->generateSignatureInput($contains_body, $timestamp, $this->signatureConfig);
        $headers[] = "Signature: " . $this->signatureService->generateSignature($contains_body, $headers, $method, $endpoint, $timestamp, $this->signatureConfig);

        return $headers;
    }

    /**
     * Load config value into SignatureConfig Object
     *
     * @param string $configPath config path
     */
    private function loadSignatureConfig(string $privatekeystr, string $jwe): void {
        $this->signatureConfig = new SignatureConfig();

        $this->signatureConfig->privateKeyStr = $privatekeystr;
        $this->signatureConfig->digestAlgorithm = "sha-256";
        $this->signatureConfig->jwe = $jwe;
        $this->signatureConfig->signatureParams = array("content-digest", "x-ebay-signature-key", "@method", "@path", "@authority");
    }
}

SignatureConfig.php

class SignatureConfig
{
    /**
     * @required
     * @var string
     */
    public $digestAlgorithm;

    /**
     * @var string
     */
    public $privateKey;

    /**
     * @var string
     */
    public $privateKeyStr;

    /**
     * @required
     * @var string
     */
    public $jwe;

    /**
     * @required
     * @var array
     */
    public $signatureParams;
}

SignatureService.php

<?php
class SignatureService {
    /**
     * Generate Content Digest
     *
     * @param string $body request body
     * @param SignatureConfig $signatureConfig signature config
     * @return string content digest
     */
    public function generateContentDigest(string $body, SignatureConfig $signatureConfig): string {
        $cipher = trim(strtolower($signatureConfig->digestAlgorithm));

        return sprintf('%s=:%s:',
            $cipher,
            base64_encode(hash(str_replace('-', '', $cipher), $body, true)));
    }

    /**
     * Generate Signature Key Header
     *
     * @param SignatureConfig $signatureConfig signature config
     * @return string signature key
     */
    public function generateSignatureKey(SignatureConfig $signatureConfig): string {
        return $signatureConfig->jwe;
    }

    /**
     * Generate Signature Input header
     *
     * @param string $timestamp Current time
     * @param SignatureConfig $signatureConfig signature configuration
     * @return string signatureInputHeader
     */
    public function generateSignatureInput(bool $contains_body, string $timestamp, SignatureConfig $signatureConfig): string {
        $signatureParams = $signatureConfig->signatureParams;
        return sprintf('sig1=(%s);created=%s',
            $this->getParamsAsString($contains_body, $signatureParams),
            $timestamp);
    }

    /**
     * Get 'Signature' header value
     *
     * @param array $headers request headers
     * @param string $method POST, GET, PUT, DELETE, etc.
     * @param string $endpoint URL of the requested resource
     * @param string $timestamp current time
     * @param SignatureConfig $signatureConfig signature config
     * @return string signature
     */
    public function generateSignature(bool $contains_body, array $headers, string $method, string $endpoint, string $timestamp, SignatureConfig $signatureConfig): string {
        $signatureBase = $this->calculateBase($contains_body, $headers, $method, $endpoint, $timestamp, $signatureConfig);

        $privateKeyStr = $signatureConfig->privateKeyStr;

        //Signing signature base with private key
        $rsa = new Crypt_RSA();
        $private = $rsa->loadKey($privateKeyStr);

        //Signing signature base with private key
        $signed = $rsa->sign($signatureBase);

        //Creating signature from signed base string
        $signature = sprintf('sig1=:%s:', base64_encode($signed));
        return $signature;
    }

    /**
     * Method to calculate base string value
     *
     * @param array $headers request headers
     * @param string $method POST, GET, PUT, DELETE, etc.
     * @param string $endpoint URL of the requested resource
     * @param string $timestamp current time
     * @param SignatureConfig $signatureConfig signature config
     * @return string base string
     */
    private function calculateBase(bool $contains_body, array $headers, string $method, string $endpoint, string $timestamp, SignatureConfig $signatureConfig): string {
        //Signature base is a string that is signed and BASE64 encoded. Each signature param should be enclosed in double quotes.
        //Param and value separated by colon and space. Value is not enclosed.
        //Each param / param value pair needs to be in a separate line, with simple \n linebreak
        $signatureBase = '';
        $signatureParams = $signatureConfig->signatureParams;

        foreach ($signatureParams as $signatureParam) {
            switch ($signatureParam) {
                case '@method':
                    $signatureBase .= '"@method": ' . $method;
                    break;
                case '@path':
                    $signatureBase .= '"@path": ' . $this->getPath($endpoint);
                    break;
                case '@authority':
                    $signatureBase .= '"@authority": ' . $this->getAuthority($endpoint);
                    break;
                case "@target-uri":
                    $signatureBase .= '"@authority": ' . $endpoint;
                    break;
                case "@scheme":
                    $signatureBase .= '"@scheme": ' . $this->getScheme($endpoint);
                    break;
                case "@query":
                    $signatureBase .= '"@query": ' . $this->getQuery($endpoint);
                    break;
                default:
                    $found = false;
                    for ($i = 0; $i < count($headers) && $found === false; ++$i) {
                        $lowerCaseHeader = mb_strtolower($headers[$i]);
                        if (mb_strpos($lowerCaseHeader, $signatureParam) === 0) {
                            $signatureBase .= '"' . $signatureParam . '": ' . mb_substr($headers[$i], mb_strlen($signatureParam) + 2);

                            $found = true;
                        }
                    }
            }

            //Adding a linebreak between params
            $signatureBase .= "\n";
        }

        //Signature params pseudo header and timestamp are formatted differently that previous ones
        $signatureBase .= sprintf('"@signature-params": (%s);created=%s', $this->getParamsAsString($contains_body, $signatureParams), $timestamp);

        return $signatureBase;
    }

    //Getting authority from an API call endpoint
    private function getAuthority($endpoint): string
    {
        $urlParsed = parse_url($endpoint);
        $result = $urlParsed['host'];

        if (array_key_exists('scheme', $urlParsed) &&
            array_key_exists('port', $urlParsed)) {

            $scheme = $urlParsed['scheme'];
            $port = $urlParsed['port'];

            if ($scheme == "https" && $port != 443
                || $scheme == "http" && $port != 80) {
                $result .= ":" . $port;
            }
        }

        return $result;
    }

    //Getting path from an API call endpoint
    private function getPath($endpoint): string {
        return parse_url($endpoint)['path'];
    }

    private function getScheme($endpoint): string {
        return parse_url($endpoint)['scheme'];
    }

    private function getQuery($endpoint): string {
        return parse_url($endpoint)['query'];
    }

    //Getting params as string
    private function getParamsAsString(bool $contains_body, array $signature_params): string {
        //Params need to be enclosed in double quotes and separated with space
        if ($contains_body === true) {
            return '"' . implode('" "', $signature_params) . '"';
        } else {
            return '"' . implode('" "', array_filter($signature_params, function($element) {
                return strtolower($element) !== "content-digest";
            })) . '"';
        }
    }
}

This creates the following signature base:

"x-ebay-signature-key": eyJ6...
"@method": GET
"@path": /sell/finances/v1/transaction
"@authority": apiz.ebay.com
"@signature-params": ("x-ebay-signature-key" "@method" "@path" "@authority");created=1683553831

which is then signed with my private key, but it is not working. Always get 215120, Signature validation failed [longMessage], Signature validation failed to fulfill the request.

How do I get this fixed? I tried two times with different keys.

uherberg commented 1 year ago

@BlackEagleXV : Try adding this prior to the call to sign

$rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
$rsa->setHash("sha256");

With that, it worked for me. By default, the signature mode would be CRYPT_RSA_SIGNATURE_PSS and the hash function would be sha1.

BlackEagleXV commented 1 year ago

Hi @uherberg Thank you that did the trick, but I found another bug also in the SDK code from you:

foreach ($signatureParams as $signatureParam) {
      switch ($signatureParam) {
          case '@method':
              $signatureBase .= '"@method": ' . $method;
              break;
          case '@path':
              $signatureBase .= '"@path": ' . $this->getPath($endpoint);
              break;
          case '@authority':
              $signatureBase .= '"@authority": ' . $this->getAuthority($endpoint);
              break;
          case "@target-uri":
              $signatureBase .= '"@authority": ' . $endpoint;
              break;
          case "@scheme":
              $signatureBase .= '"@scheme": ' . $this->getScheme($endpoint);
              break;
          case "@query":
              $signatureBase .= '"@query": ' . $this->getQuery($endpoint);
              break;
          default:
              $found = false;
              for ($i = 0; $i < count($headers) && $found === false; ++$i) {
                  $lowerCaseHeader = mb_strtolower($headers[$i]);
                  if (mb_strpos($lowerCaseHeader, $signatureParam) === 0) {
                      $signatureBase .= '"' . $signatureParam . '": ' . mb_substr($headers[$i], mb_strlen($signatureParam) + 2);

                      $found = true;
                  }
              }
      }

      //Adding a linebreak between params
      $signatureBase .= "\n"; // BUG: this happens also in default of switch if header is not found, we don't want that
  }

This will add a \n for every missing header, meaning it is not working for a get request. You maybe check that in the PHP SDK?

uherberg commented 1 year ago

@BlackEagleXV You are right :-) Someone else just pointed this out to me a couple of days ago, and I fixed it in: https://github.com/eBay/digital-signature-php-sdk/pull/20/files

uherberg commented 1 year ago

@BlackEagleXV PS: Make sure to only call EBYGetKeyPair() when you need a new keypair (likely only the very first time you onboard a new app). You shouldn't call this method for every API call. It's not clear from the above code when you call it.

BlackEagleXV commented 1 year ago

@uherberg Thank you for pointing out, but it was just there for clarification how I got the key pair. It is not called on every API call.