简体   繁体   中英

How to verify a Paypal webhook notification DIY style (without using Paypal SDK)

Upon integrating the smart button of Paypal I have issues to verify webhook notifications sent by Paypal. The examples I have found are either outdated or do not work.

Is there a way to verify the webhook notifications, ideally in a DIY way (ie. without having to use the bulky and complex Paypal API)?

To the best of my knowledge, this code is only one that actually works. All other examples I have found on stack overflow will not work because instead of passing the ID of the webhook itself when composing the signature string, they use the ID of the webhook event, thus the verify will fail.

The webhook ID will be generated once you add the webhook in the developer backend of Paypal. After creation of the webhook you will see its id in the list of installed webhooks.

The rest is pretty straight forward: We get the headers and the HTTP body and compose the signature using Paypal's recipe:

To generate the signature, PayPal concatenates and separates these items with the pipe (|) character.

"These items" are: The transmission id, the transmission date, the webhook id and a CRC over the HTTP body. The first two can be found in the header of the request, the webhook id in the developer backend (of course, that id will never change), the CRC is calculated like shown below.

The certificate's location is in the header, too, so we load it and extract the private key.

Last thing to watch out for: The name of the algorithm provided by Paypal (again in a header field) is not exactly the same as understood by PHP. Paypal calls it "sha256WithRSA" but openssl_verify will expect "sha256WithRSAEncryption".

// get request headers
$headers=apache_request_headers();

// get http payload
$body=file_get_contents('php://input');

// compose signature string: The third part is the ID of the webhook ITSELF(!),
// NOT the ID of the webhook event sent. You find the ID of the webhook
// in Paypal's developer backend where you have created the webhook
$data=
    $headers['Paypal-Transmission-Id'].'|'.
    $headers['Paypal-Transmission-Time'].'|'.
    '[THE_ID_OF_THE_WEBHOOK_ACCORDING_TO_DEVELOPER_BACKEND]'.'|'.
    crc32($body);

// load certificate and extract public key
$pubKey=openssl_pkey_get_public(file_get_contents($headers['Paypal-Cert-Url']));
$key=openssl_pkey_get_details($pubKey)['key'];

// verify data against provided signature 
$result=openssl_verify(
    $data,
    base64_decode($headers['Paypal-Transmission-Sig']),
    $key,
    'sha256WithRSAEncryption'
);

if ($result==1) {
    // webhook notification is verified
    ...
}
elseif ($result==0) {
    // webhook notification is NOT verified
    ...
}
else {
    // there was an error verifying this
    ...
}

Answering this for nodejs, as there are subtle security issues and some missing logic in original (but very helpful) answer. This answer addresses the following issues:

  1. Someone putting in their own URL and thereby getting authentication of their own requests
  2. CRC needs to be an unsigned integer, not a signed integer.
  3. NodeJs < 17.0 is missing some built in X509 functionality.
  4. Ideally one should validate the signing cert with the built in cert chain but NodeJS < 17.0 can't do this easily AFAICT. The trust model relies on TLS and the built in nodejs trust chain for the cert fetch URL and not the returned cert from cert URL, which is probably good enough.
const forge = require('node-forge');
const crypto = require('crypto')
const CRC32 = require('crc-32');
const axios = require('axios');


  const transmissionId = paypalSubsEvent.headers['PAYPAL-TRANSMISSION-ID'];
  const transmissionTime = paypalSubsEvent.headers['PAYPAL-TRANSMISSION-TIME'];
  const signature = paypalSubsEvent.headers['PAYPAL-TRANSMISSION-SIG'];
  const webhookId = '<your webhook ID from your paypal dev. account>';
  const url = paypalSubsEvent.headers['PAYPAL-CERT-URL'];
  const bodyCrc32 = CRC32.str(paypalSubsEvent.body);  
  const unsigned_crc = bodyCrc32 >>> 0;     // found by trial and error

  // verify domain is actually paypal.com, or else someone
  // could spoof in their own cert
  const urlObj = new URL(url);
  if (!urlObj.hostname.endsWith('.paypal.com')) {
    throw new Error(
      `URL ${certUrl} is not in the domain paypal.com, refusing to fetch cert for security reasons`);
  }
  const validationString =
    transmissionId + '|'
    + transmissionTime + '|'
    + webhookId + '|'
    + unsigned_crc;

  const certResult = await axios.get(url);   // Trust TLS to check the URL is really from *.paypal.com
  const cert = forge.pki.certificateFromPem(certResult.data);
  const publicKey = forge.pki.publicKeyToPem(cert.publicKey)
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(validationString);
  verifier.end();
  const result = verifier.verify(publicKey, signature, 'base64');
  console.log(result);

You can use the following steps with Paypal API's

  1. Create App and get the Client ID and Secret from the Developer dashboard
  2. Create Webhook inside App and get a webhook ID

Implementation PayPal API's https://www.postman.com/paypal/workspace/paypal-public-api-workspace/collection/19024122-92a85d0e-51e7-47da-9f83-c45dcb1cdf24?action=share&creator=22959279

  1. Get the new Access token with help of Client ID and Secret, every time connect with PayPal.

4.Use the webhook Id, Access Token, and request Headers to verify the Webhook

 try{
    $json = file_get_contents('php://input');
        $data = json_decode($json);
        $paypalmode = ($this->dev_mode == 0) ? '' : '.sandbox';
        $API_Endpoint = 'https://api-m' . $paypalmode . '.paypal.com/v1/';
    //step-01 get token        
        $res_token = getToken($API_Endpoint);//get Token mention in above postman link
    //step-02 validate webhook
        $webhook_id = 'XXXXXX';
        $post_data = array(
            "webhook_id" => $webhook_id ,
            "transmission_id" => $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'],
            "transmission_time" => $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'],
            "cert_url" => $_SERVER['HTTP_PAYPAL_CERT_URL'],
            "auth_algo" => $_SERVER['HTTP_PAYPAL_AUTH_ALGO'],
            "transmission_sig" => $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'],
            "webhook_event" => $data
        );
        $res = verifyWebhook($API_Endpoint . 'notifications/verify-webhook-signature',
                $res_token['access_token'], $post_data);//use postman 'verify-webhook-signature' api mention in webhook section
        if (isset($res->verification_status) && $res->verification_status == 'SUCCESS') {
         //success
       }else{
         //failure
       }
   } catch (Exception $ex) {
        //error
   }

Responding to this to save potential headaches but the above example does not work because an authentication token is needed to be sent along with your get request for the cert file " file_get_contents($header['Paypal-Cert-Url']) " will not work on its own.

Simply include your authentication token in the header and it'll work.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM