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);

        }
    }
💡
Tip: 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.