0

Having read through eBay's guide for including digital signatures to certain of their REST API calls, I am having trouble with generating the signature header. Rather than including all of the documentation here (there is a lot!), I'll provide links to the appropriate pages and some of the documentation. The following page it the starting point provided by eBay: https://developer.ebay.com/develop/guides/digital-signatures-for-apis The next page is where I am lead to from the previous page describing how to create the signature: https://www.ietf.org/archive/id/draft-ietf-httpbis-message-signatures-13.html#name-eddsa-using-curve-edwards25 Which leads me onto the following : https://www.rfc-editor.org/rfc/rfc8032#section-5.1.6

5.1.6.  Sign

   The inputs to the signing procedure is the private key, a 32-octet
   string, and a message M of arbitrary size.  For Ed25519ctx and
   Ed25519ph, there is additionally a context C of at most 255 octets
   and a flag F, 0 for Ed25519ctx and 1 for Ed25519ph.

   1.  Hash the private key, 32 octets, using SHA-512.  Let h denote the
       resulting digest.  Construct the secret scalar s from the first
       half of the digest, and the corresponding public key A, as
       described in the previous section.  Let prefix denote the second
       half of the hash digest, h[32],...,h[63].

   2.  Compute SHA-512(dom2(F, C) || prefix || PH(M)), where M is the
       message to be signed.  Interpret the 64-octet digest as a little-
       endian integer r.

   3.  Compute the point [r]B.  For efficiency, do this by first
       reducing r modulo L, the group order of B.  Let the string R be
       the encoding of this point.

   4.  Compute SHA512(dom2(F, C) || R || A || PH(M)), and interpret the
       64-octet digest as a little-endian integer k.

   5.  Compute S = (r + k * s) mod L.  For efficiency, again reduce k
       modulo L first.

   6.  Form the signature of the concatenation of R (32 octets) and the
       little-endian encoding of S (32 octets; the three most
       significant bits of the final octet are always zero).

I have some Python code from the appendix from this same web page (https://www.rfc-editor.org/rfc/rfc8032#section-6):

## First, some preliminaries that will be needed.

import hashlib

def sha512(s):
    return hashlib.sha512(s).digest()

# Base field Z_p
p = 2**255 - 19

def modp_inv(x):
    return pow(x, p-2, p)

# Curve constant
d = -121665 * modp_inv(121666) % p

# Group order
q = 2**252 + 27742317777372353535851937790883648493

def sha512_modq(s):
    return int.from_bytes(sha512(s), "little") % q

## Then follows functions to perform point operations.

# Points are represented as tuples (X, Y, Z, T) of extended
# coordinates, with x = X/Z, y = Y/Z, x*y = T/Z

def point_add(P, Q):
    A, B = (P[1]-P[0]) * (Q[1]-Q[0]) % p, (P[1]+P[0]) * (Q[1]+Q[0]) % p;
    C, D = 2 * P[3] * Q[3] * d % p, 2 * P[2] * Q[2] % p;
    E, F, G, H = B-A, D-C, D+C, B+A;
    return (E*F, G*H, F*G, E*H);


# Computes Q = s * Q
def point_mul(s, P):
    Q = (0, 1, 1, 0)  # Neutral element
    while s > 0:
        if s & 1:
            Q = point_add(Q, P)
        P = point_add(P, P)
        s >>= 1
    return Q

def point_equal(P, Q):
    # x1 / z1 == x2 / z2  <==>  x1 * z2 == x2 * z1
    if (P[0] * Q[2] - Q[0] * P[2]) % p != 0:
        return False
    if (P[1] * Q[2] - Q[1] * P[2]) % p != 0:
        return False
    return True

## Now follows functions for point compression.

# Square root of -1
modp_sqrt_m1 = pow(2, (p-1) // 4, p)

# Compute corresponding x-coordinate, with low bit corresponding to
# sign, or return None on failure
def recover_x(y, sign):
    if y >= p:
        return None
    x2 = (y*y-1) * modp_inv(d*y*y+1)
    if x2 == 0:
        if sign:
            return None
        else:
            return 0

    # Compute square root of x2
    x = pow(x2, (p+3) // 8, p)
    if (x*x - x2) % p != 0:
        x = x * modp_sqrt_m1 % p
    if (x*x - x2) % p != 0:
        return None

    if (x & 1) != sign:
        x = p - x
    return x


# Base point
g_y = 4 * modp_inv(5) % p
g_x = recover_x(g_y, 0)
G = (g_x, g_y, 1, g_x * g_y % p)

def point_compress(P):
    zinv = modp_inv(P[2])
    x = P[0] * zinv % p
    y = P[1] * zinv % p
    return int.to_bytes(y | ((x & 1) << 255), 32, "little")

def point_decompress(s):
    if len(s) != 32:
        raise Exception("Invalid input length for decompression")
    y = int.from_bytes(s, "little")
    sign = y >> 255
    y &= (1 << 255) - 1

    x = recover_x(y, sign)
    if x is None:
        return None
    else:
        return (x, y, 1, x*y % p)

## These are functions for manipulating the private key.

def secret_expand(secret):
    if len(secret) != 32:
        raise Exception("Bad size of private key")
    h = sha512(secret)
    a = int.from_bytes(h[:32], "little")
    a &= (1 << 254) - 8
    a |= (1 << 254)
    return (a, h[32:])

def secret_to_public(secret):
    (a, dummy) = secret_expand(secret)
    return point_compress(point_mul(a, G))


## The signature function works as below.

def sign(secret, msg):
    a, prefix = secret_expand(secret)
    A = point_compress(point_mul(a, G))
    r = sha512_modq(prefix + msg)
    R = point_mul(r, G)
    Rs = point_compress(R)
    h = sha512_modq(Rs + A + msg)
    s = (r + h * a) % q
    return Rs + int.to_bytes(s, 32, "little")

## And finally the verification function.

def verify(public, msg, signature):
    if len(public) != 32:
        raise Exception("Bad public key length")
    if len(signature) != 64:
        Exception("Bad signature length")
    A = point_decompress(public)
    if not A:
        return False
    Rs = signature[:32]
    R = point_decompress(Rs)
    if not R:
        return False
    s = int.from_bytes(signature[32:], "little")
    if s >= q: return False
    h = sha512_modq(Rs + public + msg)
    sB = point_mul(s, G)
    hA = point_mul(h, A)
    return point_equal(sB, point_add(R, hA))

Now, the problem that I am having is that this code insists on the "secret" consisting of a 32 byte array:

if len(secret) != 32: raise Exception("Bad size of private key")

However, the secret is described as being the private key provided by eBay's Key Management API (https://developer.ebay.com/api-docs/developer/key-management/overview.html), which is not a 32 byte array, but a 64 character ASCII string (see https://developer.ebay.com/api-docs/developer/key-management/resources/signing_key/methods/createSigningKey#h2-samples): "privateKey": "MC4CAQAwBQYDK2VwBCIEI******************************************n"

When I try to generate a signature with the eBay private key using this Python code, it gives me an error saying it is a "Bad size of private key". If I convert the private key from eBay to a bytearray, it is 64 bytes long. How can I use the Python code to generate the signature header using the private key supplied by eBay?

To further complicate things, I am actually using Excel VBA (Visual Basic) to make the API call after using Python to generate the signature (simply because Python is better at this kind of thing!). eBay's PAID FOR technical support has confirmed that the following headers are correct and that there is no "message" as described in https://www.rfc-editor.org/rfc/rfc8032#section-5.1.6, but they have not yet been of any further help other than suggesting that there may be a "bug".

http.setRequestHeader "signature-input", "sig1=(""x-ebay-signature-key"" ""@method"" ""@path"" ""@authority"");created=1667386210"
http.setRequestHeader "x-ebay-signature-key", "<jwe returned by eBay>"
http.setRequestHeader "x-ebay-enforce-signature", "true"

The remaining header would be as follows once I can generate a valid signature:

http.setRequestHeader "signature" "sig1=:<signature>:"

Everything I have tried results in the same response:

{
  "errors": [
    {
      "errorId": 215122,
      "domain": "ACCESS",
      "category": "REQUEST",
      "message": "Signature validation failed",
      "longMessage": "Signature validation failed to fulfill the request."
    }
  ]
}

Here are some example keys like the ones generated by eBay. https://www.ietf.org/archive/id/draft-ietf-httpbis-message-signatures-11.html#appendix-B.1.4

"The following key is an elliptical curve key over the Edwards curve ed25519, referred to in this document as test-key-ed25519. This key is PCKS#8 encoded in PEM format, with no encryption."

-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=
-----END PUBLIC KEY-----

-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
-----END PRIVATE KEY-----

This is the format of private key that I believe that I need to convert to a 32-byte array to work with the above Python code. I believe that there is a typo on the linked to web page and it should be "PKCS", not "PCKS".

UPDATE: If I run the following command:

openssl ec -in test.pem -text

Where test.pem is a text file containing:

-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
-----END PRIVATE KEY-----

It displays private and public keys as 32 byte hex dumps, but even when using these values I get the same response as above with the 215122 error. When I verify using the Python "verify" method in the code above with these 32 byte hex dump keys, validation is successful.

atl-it
  • 41
  • 6
  • I have made some progress with this, but if no-one on this site is interested I won't both updating it. – atl-it Nov 17 '22 at 12:43
  • I'm trying to do this with PHP, what a nightmare ! I've never seen an API so poorly documented, overly complex examples without code snippets. :( – Renegade_Mtl Nov 25 '22 at 22:29
  • I agree. The documentation is terrible and overly complicated. What part are you stuck on? I don't understand the actual Signature algorithm as I used the Python code supplied in the documentation which actually does work. It was the "Signature-Input" field and input message for the signature algorithm that I was stuck with. – atl-it Nov 27 '22 at 05:51
  • Love to know what progress you've made - like @Renegade_Mtl trying to implement this in PHP and its a nightmare... – Xrender Nov 29 '22 at 00:39

2 Answers2

1

I'm going to put this here for anyone struggling to get this working with PHP, adapted from Renegade_Mtl answer (you'd missed the need for a new line for each signature_base and it didn't need to be encoded).

/**
 * @param $method - e.g. POST, GET
 * @param $path - e.g /sell/finances/v1/seller_funds_summary
 * @param $host - e.g. api.ebay.com
 * @param $keyset // public, private and jwt keys generated from https://apiz.ebay.com/developer/key_management/v1/signing_key
 * @param $timestamp - e.g. time()
 * @return array of headers
 */
private function createHeaders(string $method, string $path, string $host, array $tokens, int $time) {
    $signature_input_txt = '("x-ebay-signature-key" "@method" "@path" "@authority");created=' . $time;

    // $signature_base = '"content-digest": sha-256=:' . base64_encode($contentDigest) . ":\n";
    $signature_base = '"x-ebay-signature-key": ' . $tokens['jwe']."\n";
    $signature_base .= '"@method": ' . $method."\n";
    $signature_base .= '"@path": ' . $path."\n";
    $signature_base .= '"@authority": ' . $host."\n";
    $signature_base .= '"@signature-params": ' . $signature_input_txt;
  
    // format the private key as required
    $formatted_private_key = "-----BEGIN RSA PRIVATE KEY-----" . PHP_EOL . $tokens['privateKey'] . PHP_EOL . "-----END RSA PRIVATE KEY-----";

    openssl_sign($signature_base, $signed_signature, $formatted_private_key, "sha256WithRSAEncryption");
    return [
        'Signature-Input' => 'sig1=' . $signature_input_txt,
        'Signature' => 'sig1=:' . base64_encode($signed_signature) . ':',
        'x-ebay-signature-key' => $tokens['jwe'],
        'x-ebay-enforce-signature' => "true"
    ];
}

We only use GET's but if you also POST then you'd need also the content digest... Hope this helps someone from wasting hours and hours trying to figure it out.

Xrender
  • 1,425
  • 4
  • 20
  • 25
  • Hi @Xrender ! Thanks for the response, have you personally tried this ? I can't get it to sign so far! I get the "Signature validation failed" 215120 error. You are using the RSA tokens not the ED right ? Host is api and not apiz.ebay.com and the base_uri for the call is http://api.ebay.com/ ? – Renegade_Mtl Dec 01 '22 at 22:12
  • I tried using both RSA and ED25519, when I use the RSA I leave the encoding to "sha256WithRSAEncryption", when I try ED25519 I use the OPENSSL_ALGO_SHA256 encryption. I tried pretty much everything. Im wondering if it boils down to the path and host ? – Renegade_Mtl Dec 02 '22 at 04:39
  • Hey @Renegade_Mtl yep its working for me. Have you tried with the simplest example of /sell/finances/v1/seller_funds_summary and api.ebay.com ? e.g. no other params. I'm using a RSA Key generated from https://developer.ebay.com/api-docs/developer/key-management/resources/signing_key/methods/createSigningKey The only thing missing is to add the normal Authentication header (I just append the additional headers) – Xrender Dec 05 '22 at 02:31
  • GET requests work fine, Im just trying to work out the POST requests with the content-digest. – Renegade_Mtl Dec 06 '22 at 03:16
  • FYI... eBay just released the SDK for PHP https://github.com/eBay/digital-signature-php-sdk Have not tried it yet. – Renegade_Mtl Dec 06 '22 at 03:17
  • 1
    Glad at least you got GET to work - typical they just rolled out the SDK after spending hours trying to get it to work :) – Xrender Dec 06 '22 at 21:39
  • @Xrender can you please help me with https://stackoverflow.com/questions/75066145/ebay-digital-signatures-for-apis-215120-signature-validation-failed-to-fulfil ? – Vicky Thakor Jan 10 '23 at 08:20
0

Alright so this is where Im at right now, not using the content-digest as it's simply a GET request so just trying to get the basics working, but none of this seems to work.

    $public = "xxx";
    $private = "yyy";
    $jwe = "jwe";
    $path = "/sell/fulfillment/v1/order/" . "11-xxxx-yyyy";
    $signature_input_txt = '("x-ebay-signature-key" "@method" "@path" "@authority");created=' . time();

    // $signature_base = '"content-digest": sha-256=:' . base64_encode($contentDigest) . ":\n";
    $signature_base = '"x-ebay-signature-key": ' . $jwe;
    $signature_base .= '"@method": POST';
    $signature_base .= '"@path": ' . $path;
    $signature_base .= '"@authority": ' . "apiz.ebay.com";
    $signature_base .= '"@signature-params": ' . $signature_input_txt;

    // ensure signature_base is UTF-8
    if (!mb_check_encoding($signature_base, 'UTF-8')) {
        $signature_base = mb_convert_encoding($signature_base, 'UTF-8');
    }


    // dd($signature_base);
    // base 64 encode our signature_base
    $signature_base_base64_encoded = base64_encode($signature_base);

    // format the private key as required
    $formatted_private_key = "-----BEGIN RSA PRIVATE KEY-----" . PHP_EOL . $private . PHP_EOL . "-----END RSA PRIVATE KEY-----";

    // sign
    openssl_sign($signature_base_base64_encoded, $signed_signature, $formatted_private_key, "sha256WithRSAEncryption");

    return [
        'Authorization' => 'Bearer ' . $this->marketplace->getToken('oauth2.access_token', 'production'),
        'Accept'        => 'application/json',
        'Content-Type'  => 'application/json',
        'Signature-Input' => 'sig1=' . $signature_input_txt,
        'Signature' => 'sig1=:' . base64_encode($signed_signature) . ':',
        'x-ebay-signature-key' => $jwe,
        'x-ebay-enforce-signature' => true
    ];
Renegade_Mtl
  • 430
  • 3
  • 8
  • 1
    Feel you're pain, there is a SDK for Java and Node but not for PHP, it's really confusing – Xrender Nov 29 '22 at 00:40
  • The code looks good to me. However, I opted for ED25519 as RSA looked much more complicated. Also, I don't think you need the add a digital signature for `/sell/fulfillment/v1/order` calls... Try using `$path = /sell/finances/v1/seller_funds_summary` if finances is in your scope. Full URL for the live call is `https://apiz.ebay.com/sell/finances/v1/seller_funds_summary`. There's another topic with more information: https://stackoverflow.com/questions/74530218/ebay-digital-signatures-for-apis-vba-okay-but-python-signature-validation-failed – atl-it Nov 29 '22 at 15:15
  • @Renegade_Mtl - I managed to get this to work, I put full workings below, thanks for you and atl-it for the info – Xrender Nov 30 '22 at 03:28
  • Unfortunately I can't seem to get your example to work. – Renegade_Mtl Dec 01 '22 at 22:24
  • Yes using rsa, did you get keys for eBay’s key api? Also try with the simplest api /seller_funds_summary – Xrender Dec 02 '22 at 06:59
  • Actually got it working on that endpoint. Now the fun part is making it work with the content-digest. ;) – Renegade_Mtl Dec 02 '22 at 18:33
  • Hi I'm struggling making this work on PHP. I get error when doing openssl_sign() ERROR: Warning: openssl_sign(): supplied key param cannot be coerced into a private key Is there any additional processing I should do for $ebayPrivateKey? @Xrender – jean chu Jan 30 '23 at 13:02