the cloud way

PayPal Checkout .NET SDK Verify Webhook Requests

Published Reading time
image

PayPal has deprecated their .NET Checkout SDK. However by forking the code and making some improvements; their is new life for the once retired project. In this article, we do a quick summary of the new features added to the Elcamino.PayPalCheckoutSDK and how to verify a PayPal webhook request.

New Features

v2.0 - v2.2

  • Small perf increase in VerifyWebhookEvent.ValidateReceivedEventAsync
  • Adding VerifyWebhookEvent.ValidateReceivedEventAsync to validate webhook events client side
  • #10 fix for token expiration tracking in AccessToken
  • Adding .net6 and .net7 support
  • Adding System.Text.Json serialization options
  • Dropping .netstandard support
  • ElCamino.PayPalHttp 2.0 (HttpClient performance improvements)

v1.9

Let's verify a webhook

Add a reference to nuget Elcamino.PayPalCheckoutSDK

dotnet add package ElCamino.PayPalCheckoutSdk --version 2.2.0

or

<PackageReference Include="ElCamino.PayPalCheckoutSdk" Version="2.2.0" />

Here is a sample MVC action that would accept a Post method, validate the payload

        public async Task<IActionResult> Post()
        {
            using StreamReader reader = new StreamReader(Request.Body);
            string requestWebEventBody = await reader.ReadToEndAsync();

            var verifySignature = new VerifyWebhookSignature()
            {
                CertUrl = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.CertUrl],
                AuthAlgo = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.AuthAlgo],
                TransmissionId = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionId],
                TransmissionSig = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionSig],
                TransmissionTime = Request.Headers[PayPalCheckoutSdk.HeaderNameConstants.VerifySignature.TransmissionTime],
                WebhookId = "<your webhook id>",
                WebhookEventRequestBody = requestWebEventBody
            };

            bool isValid = await VerifyWebhookEvent.ValidateReceivedEventAsync(verifySignature);
            //Do something based on webhook data being validated or not
            //Further process the payload based on https://developer.paypal.com/docs/api/webhooks/v1/
            return NoContent();

What does VerifyWebhookEvent.ValidateReceivedEventAsync(verifySignature) do?

  • Makes sure all of the signature attributes are available
  • Calculates a CRC32 checksum using the request body.
  • Generates the expected signature.
  • Loads the remote trusted certificate from CertUrl.
  • Get the local certificate thumbprint from the embedded resource. This certificate was distributed with an earlier version of the PayPal SDK.
  • Create and configure the X509Chain object to validate remote certificate chain.
  • Check remote certificate (from CertUrl) contains the local thumbprint in the certificate chain.
  • Finally, verify the received signature matches the expected signature.
    public static class VerifyWebhookEvent
    {
        private const string WithRSAToken = "withRSA";

        private static string? _publicLocalCertificateThumbprint;

        public static async Task<bool> ValidateReceivedEventAsync(VerifyWebhookSignature verifySignature)
        {
            if (string.IsNullOrWhiteSpace(verifySignature.TransmissionTime))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionTime)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.TransmissionId))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionId)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.TransmissionSig))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.TransmissionSig)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.AuthAlgo))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.AuthAlgo)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.CertUrl))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.CertUrl)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.WebhookEventRequestBody))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.WebhookEventRequestBody)} is null or empty");
            }

            if (string.IsNullOrWhiteSpace(verifySignature.WebhookId))
            {
                throw new ArgumentNullException(nameof(verifySignature), $"{nameof(verifySignature.WebhookId)} is null or empty");
            }

            // Convert the provided auth alrogithm header into a known hash alrogithm name.
            if (!verifySignature.AuthAlgo.EndsWith(WithRSAToken))
            {
                throw new ArgumentException($"{nameof(verifySignature.AuthAlgo)} must end with {WithRSAToken}", nameof(verifySignature));
            }
            string hashAlgorithm = verifySignature.AuthAlgo.Replace(WithRSAToken, "");
            string? oid = CryptoConfig.MapNameToOID(hashAlgorithm);
            if (string.IsNullOrWhiteSpace(oid))
            {
                throw new Exception($"Invalid OID from {hashAlgorithm}");
            }
            // Calculate a CRC32 checksum using the request body.
            byte[] bytes = Encoding.UTF8.GetBytes(verifySignature.WebhookEventRequestBody);
            uint crc32 = Crc32Algorithm.Compute(bytes);

            // Generate the expected signature.
            var expectedSignature = string.Format("{0}|{1}|{2}|{3}", verifySignature.TransmissionId, verifySignature.TransmissionTime, verifySignature.WebhookId, crc32);
            var expectedSignatureBytes = Encoding.UTF8.GetBytes(expectedSignature);

            // Load the remote trusted certificate.
            using System.Net.Http.HttpClient httpClient = new System.Net.Http.HttpClient();
            byte[] certificateBytes = await httpClient.GetByteArrayAsync(verifySignature.CertUrl);

            X509Certificate2Collection remoteCertificateCollection = new X509Certificate2Collection();
            remoteCertificateCollection.Import(certificateBytes);

            //Get the local cert thumbprint from the embedded resource 
            if (string.IsNullOrWhiteSpace(_publicLocalCertificateThumbprint))
            {
                using var publicCertStream = typeof(Event).Assembly.GetManifestResourceStream("PayPalCheckoutSdk.Webhooks.DigiCertSHA2ExtendedValidationServerCA.crt");
                byte[] resourceBytes = new byte[publicCertStream!.Length];
                _ = await publicCertStream.ReadAsync(resourceBytes);

                X509Certificate2 publicLocalCertificate = new X509Certificate2(resourceBytes);
                _publicLocalCertificateThumbprint = publicLocalCertificate.Thumbprint;
            }
            // Create and configure the X509Chain object to validate remote certificate
            using X509Chain chain = new X509Chain();
            chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; 
            chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
            chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;

            bool validateRemoteCert = chain.Build(remoteCertificateCollection[0]);
            if (!validateRemoteCert)
            {
                throw new Exception($"Invalid chain on remote certificate {verifySignature.CertUrl}");
            }

            // Validate the certificate chain.
            bool validateChain = chain.ChainElements.Any(a => a.Certificate.Thumbprint == (_publicLocalCertificateThumbprint?? string.Empty));
            if (!validateChain)
            {
                throw new Exception($"Invalid remote certificate, public key not found in chain {verifySignature.CertUrl}");
            }

            // Verify the received signature matches the expected signature.
            using var rsa = remoteCertificateCollection[0].GetRSAPublicKey() ?? throw new Exception($"GetRSAPublicKey() failed on remote certificate, {verifySignature.CertUrl}");
            var signatureBytes = Convert.FromBase64String(verifySignature.TransmissionSig);
            return rsa.VerifyData(expectedSignatureBytes, signatureBytes, HashAlgorithmName.FromOid(oid), RSASignaturePadding.Pkcs1);

        }
    }

Warning

This VerifyWebhookEvent.ValidateReceivedEventAsync() method is only available .NET 6 and higher.

Summary

This was a quick summary of a great .NET PayPal resource to accelerate your integration with the latest versions. Also, we took a look a validation a webhook payload with only few lines of code with a breakdown of the verify signature process.

Open a discussion

Open Source Projects

 
image
Project Title

Project intro lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cum sociis natoque penatibus et magnis dis parturient montes.

NuGet Badge   NuGet Badge

image
Project Title

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

NuGet Badge   NuGet Badge

image
Project Title

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

NuGet Badge

image
Project Title

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

NuGet Badge