paytrail/api-documentation

Can't create a matching signature with response

jesseSalonen opened this issue · 2 comments

Hello!

I'm trying to integrate Paytrail into my Laravel API, but have now ran into problems.

I have been testing the API for a while now and can't get the response's signature header to match with my own calculated one.
I have used the documentation HMAC example with php to test the endpoint for creating a new payment.

My own calculated HMAC is the same as in the example with same header values, but I get the "signature mismatch" -error in response. I can' find the source for this problem.

Is there something wrong with my code, or am I missing something?

Thank you for your help!

The code:

    private $testAccount;
    private $testSecret;
    private $endpoint;
    
    function __construct()
    {
        $this->testAccount = 375917;
        $this->testSecret = 'SAIPPUAKAUPPIAS';
        $this->endpoint = 'https://services.paytrail.com/';
    }
    
    public function startPaytrailPayment(Request $request): \Illuminate\Http\JsonResponse {
        try {
            $method = 'POST';
            $nonce = '564635208570151';
            $timestamp = '2018-07-06T10:01:31.904Z'; // ISO 8601 date time

            $params = [
                'stamp' =>  'unique-identifier-for-merchant',
                'reference' => '3759170',
                'amount' => 1525,
                'currency' => 'EUR',
                'language' => 'FI',
                'items' => [
                    [
                        'unitPrice' => 1525,
                        'units' => 1,
                        'vatPercentage' => 24,
                        'productCode' => '#1234',
                        'deliveryDate' => '2018-09-01'
                    ]
                ],
                'customer' => [
                    'email' => 'test.customer@example.com'
                ],
                'redirectUrls' => [
                    'success' => 'https://ecom.example.com/cart/success',
                    'cancel' => 'https://ecom.example.com/cart/cancel'
                ]
            ];

            $headers = $this->getPaytrailHeaders($method, $nonce, $timestamp);
            $body = $this->getPaytrailBody($method, $params);
            $headers['signature'] = $this->calculateHmac($headers, $body);

            $response = Http::withHeaders($headers)->post($this->endpoint.'payments', ['body' => $body]);

            if ($response->successful()) {
                if ($this->isResponseHmacValid($method, $response, $nonce, $timestamp)) {
                    return response()->json(
                        $response->json(),
                        Response::HTTP_OK
                    );
                } else {
                    return response()->json([
                        "message" => "Paytrail response HMAC signature mismatch!"
                    ], Response::HTTP_INTERNAL_SERVER_ERROR);
                }

            } else {
                if ($response->serverError()) {
                    return response()->json([
                        "message" => $response->json()
                    ], Response::HTTP_INTERNAL_SERVER_ERROR);
                }
                return response()->json([
                    "message" => $response->json()
                ], Response::HTTP_BAD_REQUEST);
            }

        } catch (\Exception $ex) {

            return response()->json([
                "message" => "Failed to start payment process."
            ], Response::HTTP_INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * Calculate Checkout HMAC
     *
     * @param string                $secret Merchant shared secret key
     * @param array[string]string   $params HTTP headers or query string
     * @param string                $body HTTP request body, empty string for GET requests
     * @return string SHA-256 HMAC
     */
    private function calculateHmac($params, $body = '')
    {
        // Keep only checkout- params, more relevant for response validation. Filter query
        // string parameters the same way - the signature includes only checkout- values.
        $includedKeys = array_filter(array_keys($params), function ($key) {
            return preg_match('/^checkout-/', $key);
        });

        // Keys must be sorted alphabetically
        sort($includedKeys, SORT_STRING);

        $hmacPayload =
            array_map(
                function ($key) use ($params) {
                    return join(':', [ $key, $params[$key] ]);
                },
                $includedKeys
            );


        array_push($hmacPayload, $body);

        $hmac = hash_hmac('sha256', join("\n", $hmacPayload), $this->testSecret);
        return $hmac;
    }

    private function getPaytrailHeaders($method, $nonce, $timestamp) {
        return [
            'checkout-account' => $this->testAccount,
            'checkout-algorithm' => 'sha256',
            'checkout-method' => $method,
            'checkout-nonce' => $nonce,
            'checkout-timestamp' => $timestamp,
            'content-type' => 'application/json; charset=utf-8'
        ];
    }

    private function getPaytrailBody($method, array $params) {
        if ($method === "GET") {
            return '';
        } else if (!empty($params)) {
            return json_encode(
                $params,
                JSON_UNESCAPED_SLASHES
            );
        } else {
            return '';
        }
    }

    private function isResponseHmacValid($method, \Illuminate\Http\Client\Response $response, $nonce, $timestamp) {
        $headers = $response->headers();

        if ($method === "GET") {
            $responseBody = "";
        } else {
            $responseBody = $response->body();
        }

        // Flatten Guzzle response headers
        $responseHeaders = array_column(array_map(function ($key, $value) {
            return [ $key, $value[0] ];
        }, array_keys($headers), array_values($headers)), 1, 0);

        if ($method === "GET") {
            if (!isset($responseHeaders["checkout-nonce"])) {
                $responseHeaders["checkout-nonce"] = $nonce;
            }

            if (!isset($responseHeaders["checkout-timestamp"])) {
                $responseHeaders["checkout-timestamp"] = $timestamp;
            }
        }

        $responseHmac = $this->calculateHmac($responseHeaders, $responseBody);

        if ($responseHmac !== $response->header('signature')[0]) {
            return false;
        } else {
            return true;
        }

    }

The payload for HMAC function:

checkout-account:375917
checkout-algorithm:sha256
checkout-method:POST
checkout-nonce:564635208570151
checkout-timestamp:2018-07-06T10:01:31.904Z
{"stamp":"unique-identifier-for-merchant","reference":"3759170","amount":1525,"currency":"EUR","language":"FI","items":[{"unitPrice":1525,"units":1,"vatPercentage":24,"productCode":"#1234","deliveryDate":"2018-09-01"}],"customer":{"email":"test.customer@example.com"},"redirectUrls":{"success":"https://ecom.example.com/cart/success","cancel":"https://ecom.example.com/cart/cancel"}}  

HMAC: 3708f6497ae7cc55a2e6009fc90aa10c3ad0ef125260ee91b19168750f6d74f6

Here are all the request headers:

array (
  'checkout-account' => 375917,
  'checkout-algorithm' => 'sha256',
  'checkout-method' => 'POST',
  'checkout-nonce' => '564635208570151',
  'checkout-timestamp' => '2018-07-06T10:01:31.904Z',
  'content-type' => 'application/json; charset=utf-8',
  'signature' => '3708f6497ae7cc55a2e6009fc90aa10c3ad0ef125260ee91b19168750f6d74f6',
)

The response:
{"status":"error","message":"Signature mismatch"}

@jesseSalonen Were you aware that we have made PHP-SDK available as well?
https://github.com/paytrail/paytrail-php-sdk

It might help you get up and running faster.

@loueranta-paytrail I weren't aware of that, thank you!

Now everything seems to be working correctly!